import * as cheerio from "cheerio";
import * as fs from "node:fs/promises";
import * as nodeChildProcess from "node:child_process";
import * as path from "node:path";
import * as util from "node:util";
import bib from "@retorquere/bibtex-parser";
import { optimize } from "svgo";
import sharp from "sharp";
import slugify from "@sindresorhus/slugify";
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
const exec = util.promisify(nodeChildProcess.exec);

const NUM_RE = /^(-?)[0-9]+/;
const WS_RE = /[\s\r\n]+/u;
const ANCHOR_RE = /<a(\s|>)/i;
const HTML_ESC_LUT = new Map([
    ["&", "&amp;"],
    ['"', "&quot;"],
    ["'", "&apos;"],
    ["<", "&lt;"],
    [">", "&gt;"],
]);
const PERSONAGE_TYPES = new Set(["Person", "MusicGroup"]);
const ENTRY_TO_ITEM = new Map([
    ["article", "Article"],
    ["book", "Book"],
    ["inbook", "Chapter"],
    ["incollection", "Article"],
    ["misc", "WebPage"],
]);
const MONTHS = [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December",
];

async function walk(dirPath) {
    return Promise.all(
        await fs.readdir(dirPath, { withFileTypes: true }).then(entries =>
            entries.map(entry => {
                const childPath = path.join(dirPath, entry.name);

                return entry.isDirectory() ? walk(childPath) : childPath;
            }),
        ),
    );
}

function personage(displayName, longName, href, ty, lang) {
    if (ty && !PERSONAGE_TYPES.has(ty)) {
        console.error("Bad personage type: " + ty);

        throw "";
    }
    const b = `<b class="personage"${
        ty ? ' itemscope itemtype="https://schema.org/' + ty + '"' : ""
    }>`;
    const a = href ? `<a${ty ? ' itemprop="url"' : ""} href="${href}">` : "";
    const langText = lang ? ` lang="${lang}"` : "";
    const inner = (() => {
        if (ty) {
            if (longName) {
                return (
                    `<abbr title="${longName}" itemprop="name"${langText}>${displayName}</abbr>` +
                    `<meta itemprop="alternateName" content="${longName}" />`
                );
            }

            return `<span itemprop="name"${langText}>${displayName}</span>`;
        }

        if (longName) {
            return `<abbr title="${longName}"${langText}>${displayName}</abbr>`;
        }

        if (lang) {
            return `<span${langText}>${displayName}</span>`;
        }

        return displayName;
    })();

    return `${b}${a}${inner}${a ? "</a>" : ""}</b>`;
}

function entryTypeToItemType(entryTy) {
    const itemTy =
        ENTRY_TO_ITEM.get(entryTy) ??
        (() => {
            console.error("Bad bib entry type: " + entryTy);

            throw "";
        })();

    return `https://schema.org/${itemTy}`;
}

function htmlEsc(s) {
    return s.replace(/[&"'<>]/g, c => HTML_ESC_LUT.get(c));
}

function makeHeading(content, id, level) {
    if (!id) {
        const $ = cheerio.load(content, null, false);
        id = slugify($.text());
    }

    const labelText = "#".repeat(level);

    return (
        '<div class="heading-wrapper">' +
        `<h${level} aria-labelledby="${id}--LABEL" id="${id}">` +
        content +
        `</h${level}>` +
        `<a id="${id}--LABEL" class="h${level}-label heading-label" href="#${id}">` +
        labelText +
        "</a>" +
        "</div>"
    );
}

function replaceHyphenMinuses(elem) {
    if (elem.type === "text") {
        elem.data = elem.data.replaceAll("-", "\u2010");
    }
    const title = elem.attribs?.title;
    if (title) {
        elem.attribs.title = title.replaceAll("-", "\u2010");
    }
    if (elem.children) {
        for (const child of elem.children) {
            replaceHyphenMinuses(child);
        }
    }
}

function replaceGs(elem) {
    if (elem.type === "text") {
        elem.data = elem.data.replaceAll("g", "\u0261");
    }
    if (elem.children) {
        for (const child of elem.children) {
            replaceGs(child);
        }
    }
}

function srcToReqPath(thiz, src) {
    return path.resolve(thiz.page.url, src);
}

function srcToFsPath(thiz, src) {
    return path.join(thiz.eleventy.env.root, "src", srcToReqPath(thiz, src));
}

async function makeImg(thiz, src, alt, detId) {
    const fsPath = srcToFsPath(thiz, src);
    const reqPath = srcToReqPath(thiz, src);
    const { width, height, hasAlpha } = await sharp(fsPath).metadata();
    if (!width || !height) {
        console.error(
            `Bad dimensions for ${fsPath}: ${width}\u{00d7}${height}`,
        );

        throw "";
    }
    const ly = src.endsWith(".ly.svg");
    const imgClass = hasAlpha
        ? `class="has-transparency${ly ? " ly-img" : ""}"`
        : "";
    const aClasses = (ly ? " ly-img-a" : "") + (detId ? " has-det" : "");
    const ariaDetails = detId ? `aria-details="${detId}"` : "";
    const wh = ly ? "" : `width="${width}" height="${height}"`;

    return (
        `<a href="${reqPath}" class="img-a${aClasses}">` +
        `<img ${ariaDetails} alt="${alt}" loading="lazy" decoding="async" ${wh} ${imgClass} src="${reqPath}">` +
        "</a>"
    );
}

async function imgDet(
    thiz,
    content,
    src,
    alt,
    summary = "Transcription of the above image",
) {
    const id = slugify(src) + "--DET";
    const img = await makeImg(thiz, src, alt, id);

    return (
        `${img}<details id="${id}" class="det">` +
        `<summary>${summary}</summary>${content}` +
        "</details>"
    );
}

async function makeLy(thiz, srcBase, alt) {
    const lyReqPath = srcToReqPath(thiz, `${srcBase}.ly`);
    const midReqPath = srcToReqPath(thiz, `${srcBase}.ly.mid`);
    const opusReqPath = srcToReqPath(thiz, `${srcBase}.ly.mid.opus`);
    const detContent =
        `<div class="ly-det-content"><audio preload="metadata" controls src="${opusReqPath}"></audio>` +
        `<div class="ly-det-a-container"><a class="ly-src" href="${lyReqPath}">LilyPond source</a>` +
        `<a class="midi-src" type="audio/midi" href="${midReqPath}">MIDI file (<abbr title="standard MIDI file">SMF</abbr>)</a></div></div>`;

    return await imgDet(
        thiz,
        detContent,
        `${srcBase}.ly.svg`,
        alt,
        'Playable <abbr title="Musical Instrument Digital Interface">MIDI</abbr> rendering and LilyPond source',
    );
}

async function buildLy(inputDir) {
    const postDir = path.join(inputDir, "post/");

    const paths = (await walk(postDir)).flat(Number.POSITIVE_INFINITY);
    await Promise.all(
        paths
            .filter(p => path.extname(p) === ".ly")
            .map(async lyPath => {
                const lyMtime = (await fs.stat(lyPath)).mtime;
                try {
                    const svgMtime = (await fs.stat(lyPath + ".svg")).mtime;
                    if (lyMtime < svgMtime) {
                        return;
                    }
                } catch (e) {
                    console.log(e);
                }

                return await exec(
                    `lilypond --loglevel=WARNING -dpoint-and-click=#f -ddelete-intermediate-files -dmidi-extension=mid --svg -o${lyPath} ${lyPath}`,
                ).then(out =>
                    Promise.all([
                        (async () =>
                            out?.stderr?.trim()
                                ? console.warn(out.stderr)
                                : undefined)(),
                        (async () => {
                            const svgPath = `${lyPath}.svg`;

                            const s = await fs.readFile(svgPath, {
                                encoding: "utf8",
                            });
                            await fs.writeFile(
                                svgPath,
                                s.replaceAll(
                                    'font-family="serif"',
                                    "font-family=\"'C059',serif\"",
                                ),
                                {
                                    mode: 0o664,
                                },
                            );

                            const inkscapeCmd = `inkscape --export-type=svg --vacuum-defs -l -D -T --export-area-snap --export-overwrite ${svgPath}`;
                            await exec(inkscapeCmd).catch(() =>
                                exec(inkscapeCmd),
                            );

                            const svgStr = await fs.readFile(svgPath, {
                                encoding: "utf8",
                            });

                            const $ = cheerio.load(svgStr, {
                                xml: true,
                            });
                            $("svg").attr("color", "#000000");
                            $("svg").removeAttr("width");
                            $("svg").removeAttr("height");
                            const svgStrColored = $.xml();

                            const res = optimize(svgStrColored, {
                                multipass: true,
                                path: svgPath,
                            });

                            await fs.writeFile(
                                svgPath,
                                res.data.replaceAll(
                                    'font-family="serif"',
                                    "font-family=\"'Gentium Plus',serif\"",
                                ),
                                {
                                    mode: 0o664,
                                },
                            );
                        })(),
                        exec(
                            `fluidsynth -i -q -C0 -R0 -K16 -Twav -Os16 -F ${lyPath}.mid.wav ${path.join(
                                inputDir,
                                "..",
                                "scratch",
                                "GeneralUser_GS_v2.0.0.sf2",
                            )} ${lyPath}.mid`,
                        )
                            .then(() =>
                                exec(
                                    `opusenc --quiet --bitrate 128 --vbr --comp 10 --discard-comments --discard-pictures --padding 0 ${lyPath}.mid.wav ${lyPath}.mid.opus`,
                                ),
                            )
                            .then(() => fs.rm(`${lyPath}.mid.wav`)),
                    ]),
                );
            }),
    );
}

async function parseLibrary(inputPath) {
    const bibText = await fs.readFile(`${path.dirname(inputPath)}/refs.bib`, {
        encoding: "utf-8",
    });

    return bib.parseAsync(bibText, { english: false });
}

async function getCiteIdByKey(inputPath, key) {
    const lib = await parseLibrary(inputPath);
    const entry = lib.entries.find(entry => key === entry.key);
    if (!entry) {
        console.error(`Bad cite key: "${key}". Input path: ${inputPath}\n`);

        throw "";
    }

    return getCiteId(entry.fields);
}

function joinNames(xs, surnames) {
    if (!xs) {
        return;
    }
    const filtered = surnames
        ? xs.flatMap(a => (a.lastName ? [a.lastName] : []))
        : xs.filter(s => s);
    if (filtered.length === 1) {
        return filtered[0].slice(0, 3);
    }
    if (filtered.length >= 2 && filtered.length <= 4) {
        return filtered.reduce((accu, name) => accu + name.slice(0, 1), "");
    }
    if (filtered.length > 0) {
        return (
            filtered
                .slice(0, 3)
                .reduce((accu, name) => accu + name.slice(0, 1), "") + "+"
        );
    }

    return;
}

function getCiteId(fields) {
    const alpha = (() => {
        const authorJoined = joinNames(fields.author, true);
        if (authorJoined) {
            return authorJoined;
        }
        const bookauthorJoined = joinNames(fields.bookauthor, true);
        if (bookauthorJoined) {
            return bookauthorJoined;
        }
        const editorJoined = joinNames(fields.editor, true);
        if (editorJoined) {
            return editorJoined;
        }
        const editorsJoined = joinNames(fields.editors, true);
        if (editorsJoined) {
            return editorsJoined;
        }
        const editoraJoined = joinNames(fields.editora, true);
        if (editoraJoined) {
            return editoraJoined;
        }
        const editorbJoined = joinNames(fields.editorb, true);
        if (editorbJoined) {
            return editorbJoined;
        }
        const scriptwriterJoined = joinNames(fields.scriptwriter, true);
        if (scriptwriterJoined) {
            return scriptwriterJoined;
        }
        const directorJoined = joinNames(fields.director, true);
        if (directorJoined) {
            return directorJoined;
        }
        const commentatorJoined = joinNames(fields.commentator, true);
        if (commentatorJoined) {
            return commentatorJoined;
        }
        const collaboratorJoined = joinNames(fields.collaborator, true);
        if (collaboratorJoined) {
            return collaboratorJoined;
        }
        const organizationJoined = joinNames(fields.organization);
        if (organizationJoined) {
            return organizationJoined;
        }
        const institutionJoined = joinNames(fields.institution);
        if (institutionJoined) {
            return institutionJoined;
        }
        const translatorJoined = joinNames(fields.translator, true);
        if (translatorJoined) {
            return translatorJoined;
        }

        return fields.title.slice(0, 3);
    })();
    const num = (() => {
        NUM_RE.lastIndex = 0;
        const e = NUM_RE.exec(fields.year);
        if (e) {
            const matched = e[0];

            return e[1] + matched.slice(matched.length - 2);
        }

        return fields.year.slice(fields.year.length - 2);
    })();

    return alpha + num;
}

function enOrdSuffix(n) {
    const last = n % 10;
    const penultimate = Math.trunc(n / 10) % 10;
    if (penultimate !== 1) {
        if (last === 1) {
            return "st";
        }
        if (last === 2) {
            return "nd";
        }
        if (last === 3) {
            return "rd";
        }
    }

    return "th";
}

function lowerToGreek(letter) {
    if (letter === letter.toUpperCase()) {
        return letter;
    }
    const letterPoint = letter.codePointAt(0);
    const offset = (() => {
        if (letterPoint < 0x72) {
            return 0x350;
        }
        if (letterPoint === 0x79) {
            return 0x364;
        }
        if (letterPoint === 0x7a) {
            return 0x35f;
        }

        return 0x351;
    })();

    return String.fromCodePoint(letterPoint + offset);
}

function quotes(lang = "en", nested = false) {
    switch (lang.split("-")[0].toUpperCase()) {
        case "EN":
            return nested ? ["&lsquo;", "&rsquo;"] : ["&ldquo;", "&rdquo;"];
        case "FR":
            return nested
                ? ["&ldquo;", "&rdquo;"]
                : ["&laquo;&#x202f;", "&#x202f;&raquo;"];
        default: {
            console.error("Unrecognized quote lang:", lang);

            throw "";
        }
    }
}

const SCRIPT_RE = /^[A-Z]{4}$/;
const REGION_RE = /^([A-Z]{2}|\d{3})$/;
const VAR_RE = /^([A-Z\d]{5,8}|\d[A-Z\d]{3})$/;
const PRIMARIES = new Map([
    ["ANG", "Old English"],
    ["CA", "Catalan"],
    ["CMN", "Mandarinic"],
    ["DE", "High German"],
    ["EN", "English"],
    ["FR", "French"],
    ["GRC", "Ancient Greek"],
    ["JA", "Japanese"],
    ["LA", "Latin"],
    ["PT", "Portuguese"],
    ["RU", "Russian"],
    ["SIT", "Proto-Sino-Tibetan"],
    ["SV", "Swedish"],
    ["TR", "Turkish"],
    ["UND", "[unspecified]"],
]);
const SCRIPTS = new Map([
    ["BOPO", "Bopomofo (Zhùyīn)"],
    ["HANS", "simplified Sinographs"],
    ["HANT", "traditional Sinographs"],
    ["LATN", "the Latin alphabet"],
]);
const REGIONS = new Map([]);
const VARS = new Map([
    [
        "ALALC97",
        "American Library Association \u2013 Library of Congress Romanization",
    ],
    ["FONIPA", "International Phonetic Alphabet"],
    ["HEPBURN", "Hepburn Romanization"],
    ["PINYIN", "Hànyǔ Pīnyīn Romanization"],
]);
function langToTitle(lang) {
    const tags = lang.toUpperCase().split("-");
    if (tags.length < 1) {
        return;
    }
    const primary = tags[0];
    if (primary === "EN" && tags.length === 1) {
        return;
    }

    const bail = () => {
        console.error("Bad `lang`:", lang);

        throw "";
    };
    let priv = false;
    let primaryText, script, region;
    const vars = [];
    for (const tag of tags.slice(1)) {
        if (priv) {
            switch (tag) {
                case "MSM": {
                    if (primary !== "CMN") {
                        bail();
                    }
                    primaryText = "Modern Standard Mandarin";

                    break;
                }
                default:
                    bail();
            }

            continue;
        }

        if (SCRIPT_RE.test(tag)) {
            script = script ? bail() : tag;
        } else if (REGION_RE.test(tag)) {
            region = region ? bail() : tag;
        } else if (VAR_RE.test(tag)) {
            vars.push(tag);
        } else if (tag === "X") {
            priv = true;
        } else {
            bail();
        }
    }

    const defaultPrimaryText = PRIMARIES.get(primary) ?? bail();
    if (!primaryText) {
        primaryText = defaultPrimaryText;
    }
    const ipaIx = vars.indexOf("FONIPA");
    if (ipaIx !== -1) {
        vars.splice(ipaIx, 1);
    }
    let text =
        ipaIx === -1
            ? `${primaryText} text`
            : `phonetic transcription: ${primaryText} speech`;
    if (ipaIx !== -1 && primary === "UND") {
        return "phonetic transcription";
    }
    if (region) {
        const regionText = REGIONS.get(region) ?? bail();
        text += `, as spoken in ${regionText}`;
    }
    if (script) {
        const scriptText = SCRIPTS.get(script) ?? bail();
        text += `, written in ${scriptText}`;
    }
    for (const variant of vars) {
        const varText = VARS.get(variant) ?? bail();
        text += `, ${varText}`;
    }

    return text.replaceAll("-", "\u2010");
}

/** @param {import("@11ty/eleventy").UserConfig} eleventyConfig */
export default function (eleventyConfig) {
    eleventyConfig.setUseGitIgnore(false);

    eleventyConfig.addFilter("posts", pages =>
        pages.filter(page => page.url.startsWith("/post/")),
    );
    eleventyConfig.addFilter("preview", post => {
        const $ = cheerio.load(
            post.content.split("<!-- __END_PREVIEW -->")[0],
            null,
            false,
        );
        const url = post.url + (post.url.endsWith("/") ? "" : "/");
        const anchors = $("a");
        for (const a of anchors) {
            const href = $(a).attr("href");
            if (href.startsWith("#")) {
                $(a).attr("href", url + href);
            }
        }

        return $.html();
    });
    eleventyConfig.addFilter("withtags", (pages, tagList) => {
        const tags = new Set(tagList);

        return pages.filter(page => page.data.tags.some(tag => tags.has(tag)));
    });
    eleventyConfig.addFilter("getNewestCollectionItemDate", collection =>
        collection.reduce(
            (newestDate, post) =>
                post.data.date > newestDate ? post.data.date : newestDate,
            "1970-01-01",
        ),
    );
    eleventyConfig.addFilter("htmlBaseUrl", (relativeUrl, baseUrl) => {
        if (!relativeUrl) {
            return baseUrl;
        }
        while (relativeUrl[0] === "/" || relativeUrl[0] === ".") {
            relativeUrl = relativeUrl.slice(1);
            if (!relativeUrl) {
                return baseUrl;
            }
        }
        while (baseUrl.at(-1) === "/") {
            baseUrl = baseUrl.slice(0, baseUrl.length - 1);
        }

        return `${baseUrl}/${relativeUrl}`;
    });
    eleventyConfig.addFilter(
        "atomRender",
        (content, baseUrl, absolutePostUrl) =>
            `<base href="${baseUrl}" />` +
            content.split("<!-- __END_PREVIEW -->")[0].trim() +
            `<footer>[<a href="${absolutePostUrl}"><i>&hellip;Read the full post here.</i></a>]</footer>`,
    );

    eleventyConfig.addPassthroughCopy("src/css");
    eleventyConfig.addPassthroughCopy("src/fonts");
    eleventyConfig.addPassthroughCopy("src/img");
    eleventyConfig.addPassthroughCopy("src/js/*.js");
    eleventyConfig.addPassthroughCopy("src/**/*.avif");
    eleventyConfig.addPassthroughCopy("src/**/*.webp");
    eleventyConfig.addPassthroughCopy("src/**/*.jpeg");
    eleventyConfig.addPassthroughCopy("src/**/*.svg");
    eleventyConfig.addPassthroughCopy("src/**/*.ly");
    eleventyConfig.addPassthroughCopy("src/**/*.mid");
    eleventyConfig.addPassthroughCopy("src/**/*.opus");

    eleventyConfig.addWatchTarget("src/fonts");
    eleventyConfig.addWatchTarget("src/fonts/**/*");
    eleventyConfig.addWatchTarget("src/js/*.ts");
    eleventyConfig.addWatchTarget("src/js/*.json");
    eleventyConfig.addWatchTarget("src/**/*.bib");
    eleventyConfig.addWatchTarget("src/**/*.avif");
    eleventyConfig.addWatchTarget("src/**/*.webp");
    eleventyConfig.addWatchTarget("src/**/*.jpeg");
    eleventyConfig.addWatchTarget("src/**/*.svg");
    eleventyConfig.addWatchTarget("src/**/*.ly");
    eleventyConfig.addWatchTarget("src/**/*.mid");
    eleventyConfig.addWatchTarget("src/**/*.opus");
    eleventyConfig.watchIgnores.add("src/tag/**/*");
    eleventyConfig.watchIgnores.add("src/post/**/*.ly.*");

    eleventyConfig.addPlugin(syntaxHighlight);

    eleventyConfig.on("eleventy.before", async ({ incremental, inputDir }) => {
        if (incremental) {
            return;
        }

        const jsInputDir = path.join(inputDir, "js/");
        const out = await exec(`npx tsc -p ${jsInputDir}`).catch(e =>
            console.error(e),
        );
        if (out?.stderr?.trim()) {
            console.error(out.stderr);
        }
    });
    eleventyConfig.on("eleventy.before", async ({ incremental, inputDir }) =>
        incremental ? undefined : await buildLy(inputDir),
    );
    const tagPaths = new Map();
    eleventyConfig.on("eleventy.before", async ({ inputDir }) => {
        const jsonText = await fs.readFile(path.join(inputDir, "tags.json"), {
            encoding: "utf8",
        });
        const tagData = JSON.parse(jsonText);

        const tagPath = path.join(inputDir, "tag/");
        await fs.rm(tagPath, { force: true, recursive: true });

        const writeTemplate = async (predecessors, prefix, vertex) => {
            const isRoot = vertex.tag === "post";
            const currPath = isRoot
                ? prefix
                : path.join(prefix, slugify(vertex.tag) + "/");
            tagPaths.set(vertex.tag, currPath.split("src", 2)[1]);

            const predecessorsIncl = isRoot
                ? []
                : predecessors.concat([vertex.tag]);
            const descendants = (
                await Promise.all(
                    vertex.children.map(vert =>
                        writeTemplate(predecessorsIncl, currPath, vert),
                    ),
                )
            ).flat();
            descendants.unshift(vertex.tag);

            let list = `<ul><li><a ${
                isRoot ? 'aria-current="page"' : ""
            } href="/tag/" aria-label="root of the hierarchy">&radic;</a><ul>`;
            for (const predecessor of predecessors) {
                list += `<li><a href="${tagPaths.get(
                    predecessor,
                )}" rel="tag">${predecessor}</a><ul>`;
            }
            if (!isRoot) {
                list += `<li><a aria-current="page" href="${tagPaths.get(
                    vertex.tag,
                )}" rel="tag">${vertex.tag}</a><ul>`;
            }
            for (const child of vertex.children) {
                list += `<li><a href="${tagPaths.get(child.tag)}" rel="tag">${
                    child.tag
                }</a></li>`;
            }
            list += "</ul>".repeat(predecessors.length + 3);

            const hierarchyH1 = isRoot
                ? '{% h1 "hierarchy-of-tags" %}Hierarchy of tags{% endh1 %}'
                : `{% h1 "hierarchy-of-tags" %}Hierarchy of tags directly related to <b class="tag-name">${vertex.tag}</b>{% endh1 %}`;

            const indexTemplate =
                `---
{
  "layout": "base.njk",
  "title": "tag: \\u201c${vertex.tag}\\u201d",
  "curr": "tags",
  "eleventyExcludeFromCollections": true,
  "noendnotes": true
}
---

{% set anchorRe = r/<a(\\s|>)/i %}
{% set tagarr = [${descendants.map(d => '"' + d + '"').join(", ")}] %}

<section aria-labelledby="hierarchy-of-tags" class="tag-hierarchy">
  ${hierarchyH1}

  ${list}
</section>
` +
                (isRoot
                    ? ""
                    : `
<section role="feed"
         aria-labelledby="posts-classified-under-this-tag"
         aria-busy="false"
         class="post-list"
         itemscope
         itemtype="https://schema.org/DataFeed"
>
  {% h1 "posts-classified-under-this-tag" %}Posts classified under <b class="tag-name">${vertex.tag}</b>{% endh1 %}
  {%- for peaust in collections.all | posts | withtags(tagarr) | datesort -%}
    {%- if not peaust.data.listless -%}
      {%- set post_slug = peaust.data.title | slugify -%}
      {%- set post_id = post_slug ~ "--HEAD" -%}
      {%- set post_info_id = post_slug ~ "--INFO" -%}
      <article aria-labelledby="{{ post_id }}"
               aria-describedby="{{ post_info_id }}"
               aria-posinset="{{ loop.index }}"
               aria-setsize="{{ loop.length }}"
               class="post-preview"
               itemprop="dataFeedElement"
               itemscope
               itemtype="https://schema.org/BlogPosting"
      >
        <header>
          {#--#}
          <h2 id="{{ post_id }}">
            {#--#}
            <a href="{{ peaust.url }}" itemprop="url"><span itemprop="name headline">
              {%- if peaust.data.titleHeading -%}
                {%- if anchorRe.test(peaust.data.titleHeading) -%}
                  {{- peaust.data.title -}}
                {%- else -%}
                  {{- peaust.data.titleHeading | safe -}}
                {%- endif -%}
              {%- else -%}
                {{- peaust.data.title -}}
              {%- endif -%}
            </span></a>
            {#--#}
          </h2>

          <div id="{{ post_info_id }}" class="post-info">
            {%- set d8 = peaust.data.date -%}
            <time itemprop="datePublished" datetime="{{ d8 }}">{{ d8 }}</time>
            {#--#}
            <span class="tags-listing">
              {#--#}
              <span class="tags-colon">Tags:&nbsp;</span>
              {#--#}
              <span class="tags-list">
                {%- for tag in peaust.data.tags -%}
                  <a href="{{ tag | tagpath }}" rel="tag" itemprop="keywords">{{ tag }}</a>
                  {%- if not loop.last -%}
                    ,
                  {% endif -%}
                {%- endfor -%}
              </span>
              {#--#}
            </span>
            {#--#}
          </div>

          {%- if peaust.data.series -%}
            <div class="assoc-series">
              {#--#}
              <span class="part-of-a-series">Part of a series:</span>
              <cite itemprop="isPartOf" itemscope itemtype="https://schema.org/CreativeWork">
                {%- series peaust.data.series, false, true -%}
              </cite>
              {#--#}
            </div>
          {%- endif -%}
        </header>
        {#--#}

        <div itemprop="abstract">
          {{- peaust.content.split("<!-- __END_PREVIEW -->")[0] | safe -}}
        </div>

        {#--#}
        <footer>
          <a href="{{ peaust.url }}" itemprop="url">&hellip;Read the full post&nbsp;&rarr;</a>
          {#--#}
        </footer>
      </article>
    {%- endif -%}
  {%- endfor -%}
</section>
`);
            await fs.mkdir(currPath, { recursive: true });
            await fs.writeFile(
                path.join(currPath, "index.njk"),
                indexTemplate,
                {
                    encoding: "utf8",
                    mode: 0o664,
                },
            );

            return descendants;
        };

        await writeTemplate([], tagPath, tagData);
    });
    eleventyConfig.addFilter("tagpath", tag => tagPaths.get(tag));
    eleventyConfig.addFilter("tagterm", tag => {
        const p = tagPaths.get(tag);
        if (!p) {
            console.error("Cannot find tag: " + tag);

            throw "";
        }

        return p
            .split("/")
            .filter(s => s)
            .slice(1)
            .join("/");
    });
    eleventyConfig.addFilter("datesort", (posts, rev) =>
        posts.sort(
            (p1, p2) =>
                (rev ? -1 : 1) * (p1.data.date > p2.data.date ? -1 : 1),
        ),
    );

    for (const level of [1, 2, 3, 4, 5, 6]) {
        eleventyConfig.addPairedShortcode(`h${level}`, (content, id) =>
            makeHeading(content, id, level),
        );
    }

    const HEADING_RE =
        /\{%[\s\r\n]*h[1-6][\s\r\n]*"([a-zA-Z0-9_\-]+)"[\s\r\n]*%\}[\s\r\n]*(.+?)[\s\r\n]*\{%[\s\r\n]*endh[1-6][\s\r\n]*%\}/gms;
    const QUO_RE = /&[lr][sd]quo;|[\u2018\u2019\u201c\u201d]/g;
    function swapQuoteLevels(s) {
        QUO_RE.lastIndex = 0;
        let seenLsquo = false;

        return s.replaceAll(QUO_RE, match => {
            switch (match) {
                case "&lsquo;":
                case "\u2018": {
                    seenLsquo = true;

                    return "&ldquo;";
                }
                case "&rsquo;":
                case "\u2019": {
                    const ret = seenLsquo ? "&rdquo;" : "&rsquo;";
                    seenLsquo = false;

                    return ret;
                }
                case "&ldquo;":
                case "\u201c":
                    return "&lsquo;";
                case "&rdquo;":
                case "\u201d":
                    return "&rsquo;";
            }
        });
    }
    eleventyConfig.addShortcode("headingref", function (id, slug) {
        const rawInp = slug
            ? this.ctx.collections.all.find(p => p.page.fileSlug === slug).page
                  .rawInput
            : this.page.rawInput;
        for (const [, matchedId, content] of rawInp.matchAll(HEADING_RE)) {
            if (id === matchedId) {
                return `&ldquo;<a href="${
                    slug ? "/post/" + slug + "/" : ""
                }#${id}">${swapQuoteLevels(content)}</a>&rdquo;`;
            }
        }

        console.error("Can't find heading with id", id, "and slug", slug);

        throw "";
    });

    eleventyConfig.addShortcode("snip", () => "<!-- __END_PREVIEW -->");
    eleventyConfig.addShortcode("post", function (slug) {
        const post = this.ctx.collections.all.find(
            p => p.page.fileSlug === slug,
        );
        if (!post) {
            console.error("No post with the slug ", slug);

            throw "";
        }

        const headline =
            post.data.titleHeading && !ANCHOR_RE.test(post.data.titleHeading)
                ? post.data.titleHeading
                : htmlEsc(post.data.title);

        return (
            `<cite itemscope itemtype="https://schema.org/BlogPosting">` +
            `<a itemprop="url" href="/post/${slug}">` +
            `<span itemprop="name headline">${headline}</span></a></cite>`
        );
    });
    eleventyConfig.addShortcode("tag", (tag, text) => {
        const p = tagPaths.get(tag);
        if (!p) {
            console.error("Unrecognized tag: " + tag);

            throw "";
        }

        return `<a href="${p}" rel="tag">${text ? text : htmlEsc(tag)}</a>`;
    });
    eleventyConfig.addShortcode(
        "series",
        (series, text, itemprop) =>
            `<a href="/series/#${slugify(series)}"${
                itemprop ? ' itemprop="url"' : ""
            }><span itemprop="name headline">${
                text ? text : htmlEsc(series)
            }</span></a>`,
    );
    eleventyConfig.addPairedShortcode("ipa", (transc, lang = "und", level) => {
        const [delimL, delimR] = (() => {
            if (!level) {
                return ["/", "/"];
            }
            switch (level) {
                case "none":
                    return ["", ""];
                case "phone":
                    return ["[", "]"];
                case "morph":
                    return ["&parsl;", "&parsl;"];
                default: {
                    console.error("Unrecognized transcription level:", level);

                    throw "";
                }
            }
        })();

        const langSplit = lang.split("-x-", 2);
        const langAttr = `lang="${langSplit[0]}-fonipa${
            langSplit.length > 1 ? "-x-" + langSplit[1] : ""
        }"`;

        const transcSegs = transc
            .split(WS_RE)
            .map(s => {
                const trimmed = s.trim();
                if (!trimmed) {
                    return "";
                }
                const $ = cheerio.load(
                    `<span id="--DUMMY--ID--">${trimmed}</span>`,
                    null,
                    false,
                );
                replaceGs($("#--DUMMY--ID--")[0]);

                return $("#--DUMMY--ID--").html();
            })
            .filter(s => s);
        if (transcSegs.length < 1) {
            console.error("Empty transcription:", transc, lang, level);

            throw "";
        }

        return transcSegs.length === 1
            ? `<span ${langAttr} class="no-wrap">${delimL}${transcSegs[0]}${delimR}</span>`
            : `<span ${langAttr}><span class="no-wrap">${delimL}${transcSegs.join(
                  '</span> <span class="no-wrap">',
              )}${delimR}</span></span>`;
    });

    const C_TIME_RE = /^c\/?$/;
    const STD_TIME_RE =
        /^(?<num>[1-9][0-9]*(\.[0-9]+)?)\/(?<denom>[1-9][0-9]*(\.[0-9]+)?)$/;
    const ADD_TIME_RE =
        /^(?<num>[1-9][0-9]*(\+[1-9][0-9]*)+)\/(?<denom>[1-9][0-9]*)$/;
    const FORM_RE = /^([a-zA-Z]'*([1-9][0-9]*)?)+$/;
    const FORM_SECT_RE_G = /([a-zA-Z])('*)([1-9][0-9]*)?/g;
    const PITCH_RE = /^([A-G])(b{1,2}|#|x|n)?(\-?[1-9][0-9]*)$/;
    const PC_RE = /^([A-G])(b{1,2}|#|x|n)?$/;
    const PC_RE_G = /([A-G])(b{1,2}|#|x|n)?/g;
    const PC_COLLECT_RE =
        /^([\{\[])([A-G](b{1,2}|#|x|n)?\s*,\s*)*[A-G](b{1,2}|#|x|n)?,?[\]\}]$/;
    const PC_PROG_RE =
        /^([A-G](b{1,2}|#|x|n)?\s*\-\-\s*)*[A-G](b{1,2}|#|x|n)?$/;
    const DEG_RE = /^(b{1,2}|#|x|n)?([1-9])\^$/;
    const INT_RE_G = /-?[0-9]{1,2}/g;
    const BIGINT_RE_G = /-?[0-9]+/g;
    const INT_COLLECT_RE =
        /^([\{\[<])(-?[0-9]{1,2}\s*,\s*)*-?[0-9]{1,2},?[\]\}>]$/;
    const FORTE_RE = /^([1-9][0-9]?\-Z?[1-9][0-9]?)([AB])?(\?)?$/;
    const NAMED_INTERVAL_RE = /^([PMmAdT])([1-9][0-9]*|T)$/;
    const CENTS_RE = /^([+\-]?[0-9]+(\.[0-9]+)?)c$/;
    const CPATH_RE = /^<([0-9]+(\s+[0-9]+)*)>$/;
    const CPATH_COMMAS_RE = /^<([0-9]*,([0-9]*,)*([0-9]*)?)>$/;
    const CPATH_SEG_RE_G = /[0-9]+/g;
    const CHORD_FACT_RE = /^(b{1,2}|#|x|n)?(1?[0-9])$/;
    const CHORD_RE =
        /^(?<letter>[A-G])(?<acci>b{1,2}|#|x|n)?(?<qual>\+\-|\-|\+|\|\-\||\-maj|\+maj|maj|sus2|sus4|o\/?|alt|5)?(?<ext>add2|add4|6|7|9|11|13)?(?<alts>((#|b)(5|9|11|13))*)(no(?<omitted>[135]))?(\/(?<slash>[A-G](b{1,2}|#|x|n)?)|inv(?<inv>[1-6]))?$/;
    const CHORD_PROG_INIT_RE =
        /^(?<letter>[A-G])(?<acci>b{1,2}|#|x|n)?(?<qual>\+\-|\-|\+|\|\-\||\-maj|\+maj|maj|sus2|sus4|o\/?|alt|5)?(?<ext>add2|add4|6|7|9|11|13)?(?<alts>((#|b)(5|9|11|13))*)(no(?<omitted>[135]))?(\/(?<slash>[A-G](b{1,2}|#|x|n)?)|inv(?<inv>[1-6]))?\-\-.+$/;
    const RNA_RE =
        /^(?<acci>b|#)?(?<roman>i|I|ii|II|iii|III|iv|IV|v|V|vi|VI|vii|VII)(?<qual>\+\-|\+|\|\-\||\-maj|\+maj|maj|sus2|sus4|o\/?|alt|5)?(?<ext>6|7|9|11|13)?(?<alts>((#|b)(5|9|11|13))*)(no(?<omitted>[135]))?(inv(?<inv>[1-6]))?(\/(?<slashAcci>b|#)?(?<slash>i|I|ii|II|iii|III|iv|IV|v|V|vi|VI|vii|VII|[1-7]))?$/;
    const RNA_PROG_INIT_RE =
        /^(?<acci>b|#)?(?<roman>i|I|ii|II|iii|III|iv|IV|v|V|vi|VI|vii|VII)(?<qual>\+\-|\+|\|\-\||\-maj|\+maj|maj|sus2|sus4|o\/?|alt|5)?(?<ext>6|7|9|11|13)?(?<alts>((#|b)(5|9|11|13))*)(no(?<omitted>[135]))?(inv(?<inv>[1-6]))?(\/(?<slashAcci>b|#)?(?<slash>i|I|ii|II|iii|III|iv|IV|v|V|vi|VI|vii|VII|[1-7]))?\-\-.+$/;
    const ALT_RE_G = /(#|b)(5|9|11|13)/g;
    const DYN_RE =
        /^((?<mezzo>m[pf])|(?<piano>s?p+)|(?<forte>s?f+)|fp|(?<forz>s?fz)|(?<rein>rfz?))$/;
    const NOTES_RE =
        /^(\[)?([rn][1-9][0-9]{0,2}\.*>?\s+)*[rn][1-9][0-9]{0,2}\.*>?\]?$/;
    const NOTE_RE_G = /([rn])([1-9][0-9]{0,2})(\.+)?(>)?/g;
    const MOTION_RE = /^(=)?([\[\{])([cosip][1-9]?)+[\]\}]$/;
    const MOTION_RE_G = /([cosip])([1-9])?/g;
    const ICV_RE =
        /^<([0-9te])([0-9te])([0-9te])([0-9te])([0-9te])([0-9te])>$/;
    const ARROW_RE =
        /^(?<deltas>((-[1-9][0-9]*|0|\+[1-9][0-9]*),)*(-[1-9][0-9]*|0|\+[1-9][0-9]*),?)?\|(?<motions>(?<norm>=)?(?<curly>\{)?([cosip][1-9]?)+\}?)?$/;
    const DELTA_RE_G = /(-[1-9][0-9]*)|(0)|(\+[1-9][0-9]*)/g;
    const FUN_RE = /^[TSDP]$/;
    const FORTE_LUT = new Map([
        ["2-1", [0, 1]],
        ["2-2", [0, 2]],
        ["3-1", [0, 1, 2]],
        ["3-2", [0, 1, 3]],
        ["3-2B", [0, 2, 3]],
        ["3-4", [0, 1, 5]],
        ["3-4B", [0, 4, 5]],
        ["3-5", [0, 1, 6]],
        ["3-5B", [0, 5, 6]],
        ["3-6", [0, 2, 4]],
        ["3-7", [0, 2, 5]],
        ["3-7B", [0, 3, 5]],
        ["3-8", [0, 2, 6]],
        ["3-8B", [0, 4, 6]],
        ["3-9", [0, 2, 7]],
        ["3-10", [0, 3, 6]],
        ["3-11", [0, 3, 7]],
        ["3-11B", [0, 4, 7]],
        ["3-12", [0, 4, 8]],
        ["4-10", [0, 2, 3, 5]],
        ["4-11", [0, 1, 3, 5]],
        ["4-11B", [0, 2, 4, 5]],
        ["4-16", [0, 1, 5, 7]],
        ["4-16B", [0, 2, 6, 7]],
        ["4-21", [0, 2, 4, 6]],
        ["4-22", [0, 2, 4, 7]],
        ["4-22B", [0, 3, 5, 7]],
        ["4-23", [0, 2, 5, 7]],
        ["4-Z29", [0, 1, 3, 7]],
        ["4-Z29B", [0, 4, 6, 7]],
        ["5-23", [0, 2, 3, 5, 7]],
        ["5-23B", [0, 2, 4, 5, 7]],
    ]);
    const DYN_LUT = new Map([
        ["r", "&#x1d18c;"],
        ["s", "&#x1d18d;"],
        ["z", "&#x1d18e;"],
        ["p", "&#x1d18f;"],
        ["m", "&#x1d190;"],
        ["f", "&#x1d191;"],
    ]);
    function accidental(ascii) {
        if (!ascii) {
            return "";
        }
        switch (ascii) {
            case "b":
                return "&flat;";
            case "bb":
                return "&#x1d12b;";
            case "#":
                return "&sharp;";
            case "x":
                return "&#x1d12a;";
            case "n":
                return "&natural;";
            default: {
                console.error("Unrecognized accidental: " + ascii);

                throw "";
            }
        }
    }
    function chordQual(ascii) {
        if (!ascii) {
            return ["", undefined];
        }
        switch (ascii) {
            case "+-":
                return ["&pm;", "split&dash;third augmented"];
            case "-":
                return ["&minus;", "minor"];
            case "+":
                return ["+", "augmented"];
            case "|-|":
                return ["&#x229f;", "split&dash;third"];
            case "-maj":
                return ["&minus;&#x2206;", "minor major"];
            case "+maj":
                return ["+&#x2206;", "augmented major"];
            case "maj":
                return ["&#x2206;", "major"];
            case "sus2":
                return ["sus2", "suspended second"];
            case "sus4":
                return ["sus4", "suspended fourth"];
            case "o/":
                return ["&#x1d1a9;", "half&dash;diminished"];
            case "o":
                return ["&deg;", "diminished"];
            case "alt":
                return ["alt", "altered"];
            case "5":
                return ["5", "power"];
            default: {
                console.error("Unrecognized chord quality notation: " + ascii);

                throw "";
            }
        }
    }
    function rnaQual(ascii) {
        switch (ascii) {
            case "-maj":
                return ["", "minor major"];
            case "+maj":
                return ["+", "augmented major"];
            case "maj":
                return ["", "major"];
            case "5":
                return ["", "power"];
            default:
                return chordQual(ascii);
        }
    }
    function chordExt(ascii) {
        if (!ascii) {
            return ["", undefined];
        }
        switch (ascii) {
            case "add2":
                return ["add2", "added second"];
            case "add4":
                return ["add4", "added fourth"];
            case "6":
                return ["6", "sixth"];
            case "7":
                return ["7", "seventh"];
            case "9":
                return ["9", "ninth"];
            case "11":
                return ["11", "eleventh"];
            case "13":
                return ["13", "thirteenth"];
            default: {
                console.error(
                    "Unrecognized chord extension notation: " + ascii,
                );

                throw "";
            }
        }
    }
    function chordAlts(ascii) {
        if (!ascii) {
            return ["", undefined];
        }
        const ret = ["", "with "];
        let first = true;
        for (const [, acci, factor] of ascii.matchAll(ALT_RE_G)) {
            const s = `${accidental(acci)}${factor}`;
            ret[0] += `${first ? "" : "&ic;"}${s}`;
            ret[1] += `${first ? "" : ", "}${s}`;
            first = false;
        }

        return ret;
    }
    function chordOmitted(ascii) {
        if (!ascii) {
            return ["", undefined];
        }
        const factor = parseInt(ascii, 10);
        if (!factor || !Number.isSafeInteger(factor)) {
            console.error("Unexpected `omitted`: " + ascii);

            throw "";
        }

        switch (factor) {
            case 1:
                return ["no1", "with omitted root"];
            case 3:
                return ["no3", "with omitted third"];
            case 5:
                return ["no5", "with omitted fifth"];
            default: {
                console.error("`chordOmitted` fallthrough: " + ascii);

                throw "";
            }
        }
    }
    function chordInv(ascii, hasSeventh, isPower) {
        if (!ascii) {
            return ["", undefined];
        }
        const n = parseInt(ascii, 10);
        switch (n) {
            case 1: {
                if (isPower) {
                    console.error("Power-chord in first inversion");

                    throw "";
                }

                return [
                    `<span class="inversion"><sup class="inversion-upper">6</sup>${
                        hasSeventh
                            ? '<sub class="inversion-lower">5</sub>'
                            : '<span class="inversion-lower"></span>'
                    }</span>`,
                    "in first inversion",
                ];
            }
            case 2:
                return [
                    isPower
                        ? '<span class="inversion"><sup class="inversion-upper">4</sup><span class="inversion-lower"></span></span>'
                        : `<span class="inversion"><sup class="inversion-upper">${
                              hasSeventh ? 4 : 6
                          }</sup><sub class="inversion-lower">${
                              hasSeventh ? 3 : 4
                          }</sub></span>`,
                    "in second inversion",
                ];
            case 3: {
                if (!hasSeventh) {
                    console.error(
                        "Chord has no seventh, but is in third inversion",
                    );

                    throw "";
                }
                if (isPower) {
                    console.error("Power-chord in third inversion");

                    throw "";
                }

                return [
                    '<span class="inversion"><sup class="inversion-upper">4</sup><sub class="inversion-lower">2</sub></span>',
                    "in third inversion",
                ];
            }
            default: {
                console.error("Unrecognized inversion: " + ascii);

                throw "";
            }
        }
    }
    function rnaInv(ext, inv, alts, hasMaj7, isPower) {
        const intExt = parseInt(ext, 10);
        const hasExt = Number.isSafeInteger(intExt);
        const hasSeventh = hasExt && intExt >= 7;

        const intInv = parseInt(inv, 10);
        const hasInv = Number.isSafeInteger(intInv);

        const parsedAlts = [];
        for (const [, acci, factor] of alts.matchAll(ALT_RE_G)) {
            parsedAlts.push([accidental(acci), parseInt(factor, 10)]);
        }

        let inline = "";
        const upper = [];
        let lower = 0;
        if (hasExt) {
            switch (intExt) {
                case 6: {
                    inline += "6";

                    break;
                }
                case 7: {
                    if (!hasInv) {
                        upper.push([hasMaj7 ? "&#x2206;" : "", 7]);
                    } else {
                        inline += hasMaj7 ? "&#x2206;" : "";
                    }

                    break;
                }
                case 9: {
                    if (!hasInv) {
                        upper.push([hasMaj7 ? "&#x2206;" : "", 7]);
                        upper.push(["", 9]);
                    } else {
                        inline += hasMaj7 ? "&#x2206;9" : "9";
                    }

                    break;
                }
                case 11: {
                    if (!hasInv) {
                        upper.push([hasMaj7 ? "&#x2206;" : "", 7]);
                        upper.push(["", 11]);
                    } else {
                        inline += hasMaj7 ? "&#x2206;11" : "11";
                    }

                    break;
                }
                case 13: {
                    if (!hasInv) {
                        upper.push([hasMaj7 ? "&#x2206;" : "", 7]);
                        upper.push(["", 13]);
                    } else {
                        inline += hasMaj7 ? "&#x2206;13" : "13";
                    }

                    break;
                }
                default: {
                    console.error("Unrecognized RNA ext:", intExt);

                    throw "";
                }
            }
        }
        if (isPower) {
            if (!hasInv) {
                upper.push(["", 5]);
            } else if (intInv === 2) {
                upper.push(["", 4]);
            } else {
                console.error("Inverted power-chord not in 2nd inversion");

                throw "";
            }
        } else if (hasInv) {
            switch (intInv) {
                case 1: {
                    upper.push(["", 6]);
                    if (hasSeventh) {
                        lower = 5;
                    }

                    break;
                }
                case 2: {
                    if (hasSeventh) {
                        upper.push(["", 4]);
                        lower = 3;
                    } else {
                        upper.push(["", 6]);
                        lower = 4;
                    }

                    break;
                }
                case 3: {
                    upper.push(["", 4]);
                    lower = 2;

                    break;
                }
                default: {
                    console.error("Bad inversion:", intInv);

                    throw "";
                }
            }
        }
        if (hasInv) {
            inline += parsedAlts.map(([s, n]) => "" + s + n).join("&ic;");
        } else {
            upper.push(...parsedAlts);
        }

        upper.sort(([, n], [, m]) => n - m);
        const invHtml = `<span class="inversion"><${
            upper.length > 0 ? "sup" : "span"
        } class="inversion-upper">${upper
            .map(([s, n]) => "" + s + n)
            .join(",")}</${upper.length > 0 ? "sup" : "span"}><${
            lower ? "sub" : "span"
        } class="inversion-lower">${lower ? lower : ""}</${
            lower ? "sub" : "span"
        }></span>`;

        return `${inline}${upper.length > 0 || lower ? invHtml : ""}`;
    }
    function romanDesc(acci, rNum) {
        const [deg, fun] = (() => {
            switch (rNum.toUpperCase()) {
                case "I":
                    return ["1st", "tonic"];
                case "II":
                    return ["2nd", "supertonic"];
                case "III":
                    return ["3rd", "mediant"];
                case "IV":
                    return ["4th", "subdominant"];
                case "V":
                    return ["5th", "dominant"];
                case "VI":
                    return ["6th", "submediant"];
                case "VII":
                    return [
                        "7th",
                        acci === "b" ? "subtonic" : "leading&dash;tone",
                    ];
            }
        })();

        return `${
            acci === "b" ? "flattened " : acci === "#" ? "sharpened " : ""
        }${deg} degree (${fun})`;
    }
    function parseChord(chordMatch) {
        const { letter, acci, qual, ext, alts, omitted, slash, inv } =
            chordMatch.groups;

        const root = `${letter}${accidental(acci)}`;
        const [qualText, qualDesc] = chordQual(qual);
        const [extText, extDesc] = chordExt(ext);
        const [altsText, altsDesc] = chordAlts(alts);
        const [omittedText, omittedDesc] = chordOmitted(omitted);
        const [, slashLetter, slashAcci] = slash
            ? PC_RE_G.exec(slash)
            : [undefined, undefined];
        const intExt = parseInt(ext, 10);
        const hasSeventh = Number.isSafeInteger(intExt) && intExt >= 7;
        const [invText, invDesc] = chordInv(inv, hasSeventh, qual === "5");

        return [
            `<span class="no-wrap">${root}${qualText}${extText}${altsText}${omittedText}${
                slashLetter
                    ? '<span class="chord-slash">/</span><span class="chord-slash-bass">' +
                      slashLetter +
                      accidental(slashAcci) +
                      "</span>"
                    : ""
            }${invText}</span>`,
            `${root} ${
                qualDesc ? qualDesc : hasSeventh ? "dominant" : "major"
            }${extDesc ? " " + extDesc : ""} chord${
                altsDesc ? " " + altsDesc : ""
            }${omittedDesc ? " " + omittedDesc : ""}${
                slashLetter
                    ? ", with " +
                      slashLetter +
                      accidental(slashAcci) +
                      " in the bass"
                    : invDesc
                    ? ", " + invDesc
                    : ""
            }`,
        ];
    }
    function parseRna(rnaMatch) {
        const {
            acci,
            roman,
            qual,
            ext,
            alts,
            omitted,
            inv,
            slashAcci,
            slash,
        } = rnaMatch.groups;

        const [qualText, qualDesc] = rnaQual(qual);
        const isPower = qual === "5";
        const invText = rnaInv(
            ext,
            inv,
            alts,
            qual && qual.indexOf("maj") !== -1,
            isPower,
        );

        const intExt = parseInt(ext, 10);
        const hasExt = Number.isSafeInteger(intExt);
        const hasSeventh = hasExt && intExt >= 7;

        const minor = roman === roman.toLowerCase();
        const defaultQual = (() => {
            if (minor) {
                return "minor";
            } else if (hasSeventh) {
                return "dominant";
            } else {
                return "major";
            }
        })();

        const [, extDesc] = chordExt(ext);
        const [, altsDesc] = chordAlts(alts);
        const [omittedText, omittedDesc] = chordOmitted(omitted);
        const [, invDesc] = chordInv(inv, hasSeventh, isPower);

        const [slashText, slashDesc] = (() => {
            if (!slash) {
                return ["", ""];
            }

            const accident = accidental(slashAcci);
            const slashDeg = Number.parseInt(slash, 10);

            if (Number.isSafeInteger(slashDeg)) {
                const deg = `${accident}${slashDeg}&#x302;`;

                return [
                    `<span class="rna-slash">/</span><span class="rna-slash-bass">${deg}</span>`,
                    `, with the ${deg} degree in the bass`,
                ];
            }

            return [
                `<span class="rna-slash">/</span><span class="rna-slash-target">${accident}${slash}</span>`,
                `, as applied to the ${romanDesc(
                    slashAcci,
                    slash,
                )} of the key`,
            ];
        })();

        return [
            `<span class="no-wrap">${accidental(
                acci,
            )}${roman}${qualText}${omittedText}${invText}${slashText}</span>`,
            `Chord on the ${romanDesc(acci, roman)}: ${
                qualDesc ? qualDesc : defaultQual
            }${extDesc ? " " + extDesc : ""} chord${
                altsDesc ? " " + altsDesc : ""
            }${omittedDesc ? " " + omittedDesc : ""}${
                invDesc ? ", " + invDesc : ""
            }${slashDesc}`,
        ];
    }
    function namedInterval(text, namedIntervalMatch) {
        if (text.indexOf("T") !== -1) {
            if (text !== "TT") {
                return;
            }

            return ["TT", "An interval of a tritone"];
        }

        const [s, qual, size] = namedIntervalMatch;
        const qualName = (() => {
            switch (qual) {
                case "P":
                    return "a perfect";
                case "M":
                    return "a major";
                case "m":
                    return "a minor";
                case "A":
                    return "an augmented";
                case "d":
                    return "a diminished";
            }
        })();
        const sizeName = (() => {
            const parsedSize = parseInt(size, 10);
            switch (parsedSize) {
                case 1:
                    return "unison";
                case 2:
                    return "second";
                case 3:
                    return "third";
                case 4:
                    return "fourth";
                case 5:
                    return "fifth";
                case 6:
                    return "sixth";
                case 7:
                    return "seventh";
                case 8:
                    return "octave";
                case 9:
                    return "ninth";
                case 10:
                    return "tenth";
                case 11:
                    return "eleventh";
                case 12:
                    return "twelfth";
                case 13:
                    return "thirteenth";
                default: {
                    console.error(
                        "Unexpected named interval size:",
                        parsedSize,
                    );

                    throw "";
                }
            }
        })();

        return [s, `An interval of ${qualName} ${sizeName}`];
    }
    function subOctaveIntToStr(n) {
        switch (n) {
            case 0:
            case 1:
            case 2:
            case 3:
            case 4:
            case 5:
            case 6:
            case 7:
            case 8:
            case 9:
                return "" + n;
            case 10:
                return "&#x218a;";
            case 11:
                return "&#x218b;";
            default:
                return;
        }
    }
    function prime(n) {
        if (n % 1 !== 0 || n < 0) {
            return;
        }
        switch (n) {
            case 0:
                return "";
            case 1:
                return "&prime;";
            case 2:
                return "&Prime;";
            case 3:
                return "&tprime;";
            default:
                return `&qprime;${prime(n - 4)}`;
        }
    }
    function motionName(text) {
        switch (text) {
            case "c":
                return "contrary";
            case "o":
                return "oblique";
            case "s":
                return "similar";
            case "i":
                return "imperfect parallel";
            case "p":
                return "perfect parallel";
            default: {
                console.error("Unrecognized motion type:", text);

                throw "";
            }
        }
    }
    function parseCpath(cpathMatch) {
        const [, inner] = cpathMatch;
        const [joined, numPoints] = inner
            .match(CPATH_SEG_RE_G)
            .map(seg => {
                const segSplit = seg.split("");

                return [segSplit.join("&ic;"), segSplit.length];
            })
            .reduce(([j, n], [s, p]) => [`${j}&ic; ${s}`, n + p]);
        const [lBrack, rBrack] =
            numPoints > 1 ? ["&lang;", "&rang;"] : ["", ""];

        return [
            `${lBrack}${rBrack ? "" : '<span class="no-wrap">'}${joined}${
                rBrack ? '<span class="no-wrap">' : ""
            }${rBrack}<sub style="font-weight: 700;">c</sub></span>`,
            numPoints > 1
                ? `A segment in contour&dash;space (c&dash;seg), consisting of ${numPoints} (not necessarily distinct) c&dash;pitches`
                : "A contour&dash;pitch (c&dash;pitch)",
        ];
    }
    function parseCpathCommas(cpathCommasMatch) {
        const [, inner] = cpathCommasMatch;
        const pts = inner.match(BIGINT_RE_G);
        const joined = pts.join(", ").replaceAll("-", "&minus;");
        const [lBrack, rBrack] =
            pts.length > 1 ? ["&lang;", "&rang;"] : ["", ""];

        return [
            `${lBrack}${rBrack ? "" : '<span class="no-wrap">'}${joined}${
                rBrack ? '<span class="no-wrap">' : ""
            }${rBrack}<sub style="font-weight: 700;">c</sub></span>`,
            pts.length > 1
                ? `A segment in contour&dash;space (c&dash;seg), consisting of ${pts.length} (not necessarily distinct) c&dash;pitches`
                : "A contour&dash;pitch (c&dash;pitch)",
        ];
    }
    function musNotate(text, ty) {
        C_TIME_RE.lastIndex = 0;
        STD_TIME_RE.lastIndex = 0;
        ADD_TIME_RE.lastIndex = 0;
        FORM_RE.lastIndex = 0;
        FORM_SECT_RE_G.lastIndex = 0;
        PITCH_RE.lastIndex = 0;
        PC_RE.lastIndex = 0;
        PC_RE_G.lastIndex = 0;
        PC_COLLECT_RE.lastIndex = 0;
        PC_PROG_RE.lastIndex = 0;
        DEG_RE.lastIndex = 0;
        INT_RE_G.lastIndex = 0;
        BIGINT_RE_G.lastIndex = 0;
        INT_COLLECT_RE.lastIndex = 0;
        FORTE_RE.lastIndex = 0;
        NAMED_INTERVAL_RE.lastIndex = 0;
        CENTS_RE.lastIndex = 0;
        CPATH_RE.lastIndex = 0;
        CPATH_COMMAS_RE.lastIndex = 0;
        CPATH_SEG_RE_G.lastIndex = 0;
        CHORD_FACT_RE.lastIndex = 0;
        CHORD_RE.lastIndex = 0;
        CHORD_PROG_INIT_RE.lastIndex = 0;
        RNA_RE.lastIndex = 0;
        RNA_PROG_INIT_RE.lastIndex = 0;
        ALT_RE_G.lastIndex = 0;
        DYN_RE.lastIndex = 0;
        NOTES_RE.lastIndex = 0;
        NOTE_RE_G.lastIndex = 0;
        MOTION_RE.lastIndex = 0;
        MOTION_RE_G.lastIndex = 0;
        ICV_RE.lastIndex = 0;
        ARROW_RE.lastIndex = 0;
        DELTA_RE_G.lastIndex = 0;
        FUN_RE.lastIndex = 0;

        switch ("" + ty) {
            case "undefined":
                break;
            case "0":
                return [text, undefined];
            case "c": {
                const cpathMatch = CPATH_RE.exec("" + text);
                if (cpathMatch) {
                    return parseCpath(cpathMatch);
                }

                const cpathCommasMatch = CPATH_COMMAS_RE.exec("" + text);
                if (cpathCommasMatch) {
                    return parseCpathCommas(cpathCommasMatch);
                }

                console.error("Couldn't parse cpath:", text);

                throw "";
            }
            case "fun": {
                const funMatch = FUN_RE.exec(text);
                if (!funMatch) {
                    return;
                }

                const [s, t] = (() => {
                    switch (text) {
                        case "T":
                            return [
                                "&#x24c9;",
                                "&ldquo;tonic&rdquo; function",
                            ];
                        case "S":
                            return [
                                "&oS;",
                                "&ldquo;subdominant&rdquo; or &ldquo;pre&dash;dominant&rdquo; function",
                            ];
                        case "D":
                            return [
                                "&#x24b9;",
                                "&ldquo;dominant&rdquo; function",
                            ];
                        case "P":
                            return [
                                "&#x24c5;",
                                "cadential function without &ldquo;dominant&rdquo; function",
                            ];
                        default: {
                            console.error(
                                "Unrecognized harmonic function:",
                                text,
                            );

                            throw "";
                        }
                    }
                })();

                return [`<span class="piggpar">${s}</span>`, t];
            }
            case "mot": {
                const [motText, norm] = text.startsWith("=")
                    ? [text.slice(1), true]
                    : [text, false];

                return [
                    `${norm ? "&vArr;" : ""}${motText}`,
                    `An instance of ${motionName(motText)} motion${
                        norm ? " (normalized)" : ""
                    }`,
                ];
            }
            case "->": {
                const arrowMatch = ARROW_RE.exec(text);
                if (!arrowMatch) {
                    return;
                }
                const { deltas, motions, norm, curly } = arrowMatch.groups;
                if (!deltas && !motions) {
                    return;
                }

                const under = deltas && motions ? "under" : "";
                const deltasStr = deltas
                    ? '<mrow><mspace depth="0" height="0" width="0.25em" />' +
                      Array.from(deltas.matchAll(DELTA_RE_G))
                          .map(m => {
                              const s = m[0].trim();
                              const parsed = parseInt(s, 10);
                              if (!Number.isSafeInteger(parsed)) {
                                  console.error("Couldn't parse delta:", text);

                                  throw "";
                              }

                              return `<mn>${s.replaceAll(
                                  "-",
                                  "&minus;",
                              )}</mn>`;
                          })
                          .join("<mo>,</mo>") +
                      '<mspace depth="0" height="0" width="0.25em" /></mrow>'
                    : "";
                const motionsStr = motions
                    ? '<mrow><mspace depth="0" height="0" width="0.25em" />' +
                      (norm ? "<mo>&vArr;</mo>" : "") +
                      (curly ? "<mo>&#x27c5;</mo><mrow>" : "") +
                      Array.from(motions.matchAll(MOTION_RE_G))
                          .map(([, ty, multi]) => {
                              const multiplicity = multi
                                  ? parseInt(multi, 10)
                                  : 1;
                              if (!Number.isSafeInteger(multiplicity)) {
                                  console.error(
                                      "Couldn't parse multiplicity:",
                                      text,
                                  );

                                  throw "";
                              }

                              return multiplicity === 1
                                  ? `<mi mathvariant="normal">${ty}</mi>`
                                  : `<msup><mi mathvariant="normal">${ty}</mi><mn>${multiplicity}</mn></msup>`;
                          })
                          .join("<mo>&ic;</mo>") +
                      (curly ? "</mrow><mo>&#x27c6;</mo>" : "") +
                      '<mspace depth="0" height="0" width="0.25em" /></mrow>'
                    : "";

                return [
                    `<math><m${under}over><mo stretchy="true" largeop="true" form="infix">&rarr;</mo>${motionsStr}${deltasStr}</m${under}over></math>`,
                    undefined,
                ];
            }
            case "icv": {
                const icvMatch = ICV_RE.exec(text);
                if (!icvMatch) {
                    return;
                }
                const [, one, two, three, four, five, six] = icvMatch;
                const pitman = c => {
                    switch (c) {
                        case "t":
                            return "&#x218a;";
                        case "e":
                            return "&#x218b;";
                        default:
                            return c;
                    }
                };
                const joined = `&lang;${pitman(one)}&ic;${pitman(
                    two,
                )}&ic;${pitman(three)}&ic;${pitman(four)}&ic;${pitman(
                    five,
                )}&ic;${pitman(six)}&rang;`;

                return [joined, `The interval&dash;class vector ${joined}`];
            }
            case "inter": {
                const namedIntervalMatch = NAMED_INTERVAL_RE.exec(text);
                if (!namedIntervalMatch) {
                    return;
                }

                return namedInterval(text, namedIntervalMatch);
            }
            case "fact": {
                const chordFactMatch = CHORD_FACT_RE.exec(text);
                if (!chordFactMatch) {
                    return;
                }
                const [, acci, nStr] = chordFactMatch;
                const n = parseInt(nStr, 10);
                const ord = enOrdSuffix(n);
                const alt = (() => {
                    switch (acci) {
                        case "b":
                            return "flattened";
                        case "bb":
                            return "doubly&dash;flattened";
                        case "#":
                            return "sharpened";
                        case "x":
                            return "doubly&dash;sharpened";
                        case "n":
                            return "unaltered";
                    }
                })();

                return [
                    `${accidental(acci)}${n}`,
                    `The ${alt ? alt + " " : ""}` +
                        (n === 1
                            ? "root of a chord"
                            : `${n}${ord} factor of a chord`),
                ];
            }
            case "form": {
                const formMatch = FORM_RE.exec(text);
                if (!formMatch) {
                    return;
                }
                const matches = Array.from(text.matchAll(FORM_SECT_RE_G));

                return [
                    "&Lang;" +
                        matches
                            .map(
                                ([, letter, p, n]) =>
                                    `${lowerToGreek(letter)}${prime(
                                        p.length,
                                    )}${n ? "<sub>" + n + "</sub>" : ""}`,
                            )
                            .join("&ic;") +
                        "&Rang;",
                    matches.length > 1
                        ? `A musical form with ${matches.length} sections`
                        : `The ${lowerToGreek(matches[0][1])}${prime(
                              matches[0][2].length,
                          )}${
                              matches[0][3] ? matches[0][3] : ""
                          } section of a musical form`,
                ];
            }
            case "chord": {
                const chordMatch = CHORD_RE.exec(text);
                if (!chordMatch) {
                    console.error("Couldn't parse chord: " + text);

                    throw "";
                }

                return parseChord(chordMatch);
            }
            default:
                return;
        }

        const cTimeMatch = C_TIME_RE.exec(text);
        if (cTimeMatch) {
            switch (cTimeMatch[0]) {
                case "c":
                    return [
                        '<span class="c-time">&#x1d134;</span>',
                        "Common time (4&frasl;4)",
                    ];
                case "c/":
                    return [
                        '<span class="c-time">&#x1d135;</span>',
                        "Alla breve (2&frasl;2; cut time)",
                    ];
                default:
                    return;
            }
        }
        const parenthesizeIfFractional = (s, noMarkup) =>
            s.includes(".")
                ? `${noMarkup ? "" : '<span class="time-sig-paren">'}(${
                      noMarkup ? "" : "</span>"
                  }${s}${noMarkup ? "" : '<span class="time-sig-paren">'})${
                      noMarkup ? "" : "</span>"
                  }`
                : s;
        const stdTimeMatch = STD_TIME_RE.exec(text);
        if (stdTimeMatch) {
            const { num, denom } = stdTimeMatch.groups;

            return [
                `<span class="time-sig no-wrap"><span class="time-sig-num">${parenthesizeIfFractional(
                    num,
                )}</span><span class="time-sig-slash">&frasl;</span><span class="time-sig-denom">${parenthesizeIfFractional(
                    denom,
                )}</span></span>`,
                `${parenthesizeIfFractional(
                    num,
                    true,
                )}&frasl;${parenthesizeIfFractional(denom, true)} time`,
            ];
        }
        const addTimeMatch = ADD_TIME_RE.exec(text);
        if (addTimeMatch) {
            const { num, denom } = addTimeMatch.groups;

            return [
                `<span class="time-sig"><span class="time-sig-num"><span class="time-sig-paren">(</span>${num}<span class="time-sig-paren">)</span></span><span class="time-sig-slash">&frasl;</span><span class="time-sig-denom">${denom}</span></span>`,
                `(${num})&frasl;${denom} time`,
            ];
        }

        const pitchMatch = PITCH_RE.exec(text);
        if (pitchMatch) {
            const [, name, acci, oct] = pitchMatch;
            const s = name + accidental(acci);
            const n = oct.replace("-", "&minus;");

            return [
                `${s}<sub>${n}</sub>`,
                `The pitch, in scientific pitch notation: ${s}${n}`,
            ];
        }

        const pcMatch = PC_RE.exec(text);
        if (pcMatch) {
            const [, name, acci] = pcMatch;
            const s = name + accidental(acci);

            return [s, `The pitchclass ${s}`];
        }

        const pcCollectMatch = PC_COLLECT_RE.exec(text);
        if (pcCollectMatch) {
            const [lBrack, rBrack, collectTy] =
                pcCollectMatch[1] === "{"
                    ? ["{", "}", "set"]
                    : ["[", "]", "row"];
            const joined = Array.from(text.matchAll(PC_RE_G))
                .map(match => match[1] + accidental(match[2]))
                .join(", ");

            return [
                `${lBrack}${joined}${rBrack}`,
                `The ${collectTy} of pitchclasses ${lBrack}${joined}${rBrack}`,
            ];
        }

        const pcProgMatch = PC_PROG_RE.exec(text);
        if (pcProgMatch) {
            const joined = Array.from(text.matchAll(PC_RE_G))
                .map(match => match[1] + accidental(match[2]))
                .join("&ndash;");

            return [joined, `The progression of pitchclasses ${joined}`];
        }

        const degMatch = DEG_RE.exec(text);
        if (degMatch) {
            const [, acci, nStr] = degMatch;
            const n = parseInt(nStr, 10);

            return [
                `${accidental(acci)}${nStr}&#x302;`,
                `The ${
                    acci === "b"
                        ? "flattened "
                        : acci === "#"
                        ? "sharpened "
                        : ""
                }${n}${enOrdSuffix(n)} degree of a key or mode`,
            ];
        }

        const intCollectMatch = INT_COLLECT_RE.exec(text);
        if (intCollectMatch) {
            const [lBrack, rBrack, collectTy] = (() => {
                switch (intCollectMatch[1]) {
                    case "{":
                        return ["{", "}", "set"];
                    case "[":
                        return ["[", "]", "row"];
                    case "<":
                        return ["&lang;", "&rang;", "sequence"];
                    default: {
                        console.error(
                            "Unrecognized intCollectMatch[1]:",
                            intCollectMatch[1],
                        );

                        throw "";
                    }
                }
            })();
            const ints = text.match(INT_RE_G).map(m => +m);
            const intStrs = ints.map(subOctaveIntToStr);
            const joined = intStrs.every(s => s)
                ? intStrs.join("&ic;")
                : ints.map(i => `${i}`.replace("-", "&minus;")).join(", ");

            return [
                `${lBrack}${joined}${rBrack}`,
                `The ${collectTy} of pitchclasses, in integer notation: ${lBrack}${joined}${rBrack}`,
            ];
        }

        const forteMatch = FORTE_RE.exec(text);
        if (forteMatch) {
            const [, forte, inv, q] = forteMatch;
            const m = forte + (inv ? inv : "");
            const pcSetClass =
                inv === "A" || inv === undefined
                    ? FORTE_LUT.get(forte)
                    : FORTE_LUT.get(m);
            if (!pcSetClass) {
                console.error("Unknown pc-set class:", m);

                throw "";
            }
            const joined = pcSetClass.map(subOctaveIntToStr).join("&ic;");
            const mDashed = m.replaceAll("-", "&dash;");
            const [lBrack, rBrack, lBrackSpan, rBrackSpan] = inv
                ? [
                      "&#x2045;",
                      "&#x2046;",
                      '<span class="piggpar">&#x2045;</span>',
                      '<span class="piggpar">&#x2046;</span>',
                  ]
                : ["&lobrk;", "&robrk;", "&lobrk;", "&robrk;"];

            return [
                `<span class="no-wrap">${lBrackSpan}${joined}${rBrackSpan}${
                    q ? "" : "<sub>" + mDashed + "</sub>"
                }</span>`,
                `The pitchclass&dash;set equivalence&dash;class, in integer notation: ${lBrack}${joined}${rBrack} (Forte number ${mDashed})`,
            ];
        }

        const namedIntervalMatch = NAMED_INTERVAL_RE.exec(text);
        if (namedIntervalMatch) {
            return namedInterval(text, namedIntervalMatch);
        }

        const centsMatch = CENTS_RE.exec(text);
        if (centsMatch) {
            const [, n] = centsMatch;
            const nm = n.replaceAll("-", "&minus;");

            return [nm + "&it;&cent;", `An interval of ${nm}&it;&nbsp;cents`];
        }

        const cpathMatch = CPATH_RE.exec(text);
        if (cpathMatch) {
            return parseCpath(cpathMatch);
        }

        const chordMatch = CHORD_RE.exec(text);
        if (chordMatch) {
            return parseChord(chordMatch);
        }

        if (CHORD_PROG_INIT_RE.exec(text)) {
            let ret = "";
            let first = true;
            for (const frag of text.split("--")) {
                CHORD_RE.lastIndex = 0;
                const chordM = CHORD_RE.exec(frag);
                if (!chordM) {
                    console.error("Couldn't parse chord prog:", text);

                    throw "";
                }
                const [s, title] = parseChord(chordM);
                if (!first) {
                    ret += "&ndash;";
                }
                ret += `<span title="${title}">${s}</span>`;
                first = false;
            }

            return [ret, undefined];
        }

        const rnaMatch = RNA_RE.exec(text);
        if (rnaMatch) {
            return parseRna(rnaMatch);
        }

        if (RNA_PROG_INIT_RE.exec(text)) {
            let ret = "";
            let first = true;
            for (const frag of text.split("--")) {
                RNA_RE.lastIndex = 0;
                const rnaM = RNA_RE.exec(frag);
                if (!rnaM) {
                    console.error("Couldn't parse RNA prog:", text);

                    throw "";
                }
                const [s, title] = parseRna(rnaM);
                if (!first) {
                    ret += "&ndash;";
                }
                ret += `<span title="${title}">${s}</span>`;
                first = false;
            }

            return [ret, undefined];
        }

        const dynMatch = DYN_RE.exec(text);
        if (dynMatch) {
            const s = text
                .split("")
                .map(c => DYN_LUT.get(c))
                .join("");
            const { mezzo, piano, forte, forz, rein } = dynMatch.groups;
            if (mezzo) {
                return [
                    s,
                    `mezzo&dash;${text.endsWith("p") ? "piano" : "forte"}`,
                ];
            }
            const suffix = (n, v) => (n < 2 ? v : "iss".repeat(n - 1) + "imo");
            if (piano) {
                return text.startsWith("s")
                    ? [s, `subito pian${suffix(text.length - 1, "o")}`]
                    : [s, `pian${suffix(text.length, "o")}`];
            }
            if (forte) {
                return text.startsWith("s")
                    ? [s, `subito fort${suffix(text.length - 1, "e")}`]
                    : [s, `fort${suffix(text.length, "e")}`];
            }
            if (text === "fp") {
                return [s, "fortepiano"];
            }
            if (forz) {
                return [s, text.startsWith("s") ? "sforzando" : "forzando"];
            }
            if (rein) {
                return [s, "rinforzando"];
            }
        }

        const notesMatch = NOTES_RE.exec(text);
        if (notesMatch) {
            const [, brack] = notesMatch;
            const joined = Array.from(text.matchAll(NOTE_RE_G))
                .map(([, rn, denomStr, dots, accent]) => {
                    const rest = rn === "r";
                    const denom = parseInt(denomStr, 10);
                    const base = (() => {
                        switch (denom) {
                            case 1:
                                return rest ? "&#x1d13b;" : "&#x1d15d;";
                            case 2:
                                return rest ? "&#x1d13c;" : "&#x1d15e;";
                            case 4:
                                return rest ? "&#x1d13d;" : "&#x1d15f;";
                            case 8:
                                return rest ? "&#x1d13e;" : "&#x1d160;";
                            case 16:
                                return rest ? "&#x1d13f;" : "&#x1d161;";
                            case 32:
                                return rest ? "&#x1d140;" : "&#x1d162;";
                            case 64:
                                return rest ? "&#x1d141;" : "&#x1d163;";
                            case 128:
                                return rest ? "&#x1d142;" : "&#x1d164;";
                            default: {
                                console.error(
                                    "Bad note duration denominator:",
                                    denom,
                                );

                                throw "";
                            }
                        }
                    })();

                    return `${base}${
                        dots ? dots.replaceAll(".", "&#x1d16d;") : ""
                    }${accent ? "&#x1d17b;" : ""}`;
                })
                .join("");

            return [
                `${
                    brack ? "&lang;&NoBreak;" : ""
                }<span class="mus-notes">${joined}</span>${
                    brack ? "&NoBreak;&rang;" : ""
                }`,
                undefined,
            ];
        }

        const motionMatch = MOTION_RE.exec(text);
        if (motionMatch) {
            const [, norm, bracket] = motionMatch;
            const [lBrack, rBrack, sq] =
                bracket === "["
                    ? ["[", "]", true]
                    : ["&#x27c5;", "&#x27c6;", false];
            const a = Array.from(text.matchAll(MOTION_RE_G));
            const joined = a
                .map(([, t, m]) => `${t}${m ? "<sup>" + m + "</sup>" : ""}`)
                .join("&ic;");
            const full = a
                .map(([, t, m]) =>
                    Array(m ? parseInt(m, 10) : 1).fill(motionName(t)),
                )
                .flat();
            const fullJoined = full.join(", ");

            return [
                `${norm ? "&vArr;" : ""}${lBrack}${joined}${rBrack}`,
                `An instance of polyphonic motion: ${fullJoined}${
                    full.length > 1
                        ? sq
                            ? ""
                            : " (in no particular order)"
                        : " motion"
                }${norm ? " (normalized)" : ""}`,
            ];
        }
    }
    eleventyConfig.addShortcode("m", (text, ty) => {
        const n = musNotate(text, ty);
        if (!n) {
            console.error(`Couldn't parse {% m "${text}" %}`);

            throw "";
        }

        return `<span class="music"${n[1] ? ' title="' + n[1] + '"' : ""}>${
            n[0]
        }</span>`;
    });

    function rankToWikidata(rank) {
        switch (rank) {
            case "genus":
                return "34740";
            case "suborder":
                return "5867959";
            case "superfamily":
                return "2136103";
            default: {
                console.error("Unrecognized rank:", rank);

                throw "";
            }
        }
    }
    eleventyConfig.addShortcode("species", (genus, species, url, abbr) => {
        const genusText =
            '<span itemprop="parentTaxon" itemscope itemtype="https://schema.org/Taxon"><meta itemprop="taxonRank" content="http://www.wikidata.org/entity/Q34740" /><' +
            (abbr
                ? `meta itemprop="name" content="${genus}" /><abbr title="${genus}">${genus[0]}.</abbr`
                : `span itemprop="name">${genus}</span`) +
            "></span>";

        return `<span itemscope itemtype="https://schema.org/Taxon"><meta itemprop="taxonRank" content="http://www.wikidata.org/entity/Q7432" />${
            url ? '<a itemprop="url" href="' + url + '">' : ""
        }<i itemprop="name">${genusText} ${species}</i>${
            url ? "</a>" : ""
        }</span>`;
    });
    eleventyConfig.addShortcode("taxon", (rank, name, parent, url) => {
        const nameTag = rank === "genus" ? "i" : "span";

        return `<span itemscope itemtype="https://schema.org/Taxon"><meta itemprop="taxonRank" content="http://www.wikidata.org/entity/Q${rankToWikidata(
            rank,
        )}" />${
            parent
                ? '<meta itemprop="parentTaxon" content="' + parent + '" />'
                : ""
        }${
            url ? '<a itemprop="url" href="' + url + '">' : ""
        }<${nameTag} itemprop="name">${name}</${nameTag}>${
            url ? "</a>" : ""
        }</span>`;
    });

    eleventyConfig.addShortcode("personage", personage);
    eleventyConfig.addShortcode("band", (name, href, lang) =>
        personage(name, undefined, href, "MusicGroup", lang),
    );
    eleventyConfig.addShortcode(
        "album",
        (name, artist, date, noArtist, noDate, nameLang, artistLang) => {
            const nameLangStr = nameLang ? ` lang="${nameLang}"` : "";
            const artistLangStr = artistLang ? ` lang="${artistLang}"` : "";
            const artistStr = artist
                ? noArtist
                    ? `<meta itemprop="byArtist creator author"${artistLangStr} content="${artist}" />`
                    : `<span itemprop="byArtist creator author"${artistLangStr}>${personage(
                          artist,
                          undefined,
                          undefined,
                          "MusicGroup",
                      )}</span>&rsquo;s `
                : "";
            const dateStr = date
                ? noDate
                    ? `<meta itemprop="datePublished" content="${date}" />`
                    : ` (<time itemprop="datePublished" datetime="${date}">${date}</time>)`
                : "";
            const untitled = name ? "" : "untitled album";

            return `<span class="work-mention" itemscope itemtype="https://schema.org/MusicAlbum">${artistStr}${untitled}<cite itemprop="name"${nameLangStr}>${name}</cite>${dateStr}</span>`;
        },
    );
    eleventyConfig.addShortcode(
        "track",
        (name, album, artist, date, showDate) => {
            const albumStr = album
                ? `<meta itemprop="inAlbum" content="${album}" />`
                : "";
            const artistStr = artist
                ? `<meta itemprop="byArtist" content="${artist}" />`
                : "";
            const dateStr = date
                ? showDate
                    ? ` (<time itemprop="datePublished" datetime="${date}">${date}</time>)`
                    : `<meta itemprop="datePublished" content="${date}" />`
                : "";

            return (
                '<span class="track work-mention" itemscope itemtype="https://schema.org/MusicRecording">' +
                `&ldquo;<span itemprop="name">${name}</span>&rdquo;${albumStr}${artistStr}${dateStr}</span>`
            );
        },
    );
    eleventyConfig.addShortcode(
        "composition",
        (name, cite, url, author, date, nameLang) => {
            const a = url ? `<a itemprop="url" href="${url}">` : "";
            const nameTag = cite ? "cite" : "span";
            const authorStr = author
                ? `<meta itemprop="composer" content="${author}" />`
                : "";
            const dateStr = date
                ? ` (<time itemprop="datePublished" datetime="${date}">${date}</time>)`
                : "";
            const nameLangStr = nameLang ? ` lang="${nameLang}"` : "";
            const [lQuo, rQuo] = cite
                ? ["", ""]
                : quotes(nameLang ? nameLang : "en");

            return `<span class="track work-mention" itemscope itemtype="https://schema.org/MusicComposition">${lQuo}${a}<${nameTag} itemprop="name"${nameLangStr}>${name}</${nameTag}>${
                a ? "</a>" : ""
            }${rQuo}${dateStr}</span>`;
        },
    );
    eleventyConfig.addShortcode(
        "litmention",
        (name, ty, author, date, noAuthor, noDate, titleLang, url) => {
            const authorStr = author
                ? noAuthor
                    ? `<meta itemprop="author creator" content="${author}" />`
                    : `<span itemprop="author creator">${personage(
                          author,
                          undefined,
                          undefined,
                          "Person",
                      )}</span>&rsquo;s `
                : "";
            const dateStr = date
                ? noDate
                    ? `<meta itemprop="datePublished" content="${date}" />`
                    : ` (<time itemprop="datePublished" datetime="${date}">${date}</time>)`
                : "";
            const [aOpen, aClose] = url
                ? ['<a itemprop="url" href="' + url + '">', "</a>"]
                : ["", ""];
            const cite =
                ty === "Poem"
                    ? `&ldquo;${aOpen}<cite${
                          titleLang ? ' lang="' + titleLang + '"' : ""
                      } class="quote-cite" itemprop="name">${name}</cite>${aClose}&rdquo;`
                    : `${aOpen}<cite${
                          titleLang ? ' lang="' + titleLang + '"' : ""
                      } itemprop="name">${name}</cite>${aClose}`;

            return `<span class="work-mention" itemscope itemtype="https://schema.org/${
                ty && ty !== "Poem" ? ty : "CreativeWork"
            }">${authorStr}${cite}${dateStr}</span>`;
        },
    );

    eleventyConfig.addPairedShortcode(
        "note",
        content =>
            '<div role="note">' +
            '<img class="note-i has-transparency" alt="&#x2139;&#xfe0f;" decoding="async" src="/img/i.svg">' +
            content +
            "</div>",
    );
    eleventyConfig.addPairedShortcode(
        "eg",
        content =>
            '<div class="eg" role="note">' +
            '<img class="eg-eg has-transparency" alt="For example: " decoding="async" src="/img/eg.svg">' +
            content +
            "</div>",
    );
    eleventyConfig.addPairedShortcode(
        "aside",
        content =>
            '<aside class="excursion">' +
            '<img class="thought-bubble has-transparency" alt="&#x1f4ad;" decoding="async" src="/img/thought.svg">' +
            content +
            "</aside>",
    );
    eleventyConfig.addPairedShortcode("bq", async function (content, cite) {
        const [href, sauce, clazz] =
            cite.startsWith("http") || cite.startsWith("//")
                ? [cite, "source", ""]
                : [
                      `#BIB--${cite}`,
                      await getCiteIdByKey(this.page.inputPath, cite),
                      " cite",
                  ];

        return (
            `<blockquote cite="${href}" class="has-inner-cite">` +
            content +
            `<footer><small><cite class="blockquote-src${clazz}">` +
            `<a href="${href}"><span class="src-brace">[</span>${sauce}<span class="src-brace">]</span></a>` +
            "</cite></small></footer>" +
            "</blockquote>"
        );
    });

    eleventyConfig.addShortcode("img", async function (src, alt) {
        return makeImg(this, src, alt);
    });
    eleventyConfig.addPairedShortcode(
        "imgdet",
        async function (content, src, alt) {
            return imgDet(this, content, src, alt);
        },
    );
    eleventyConfig.addShortcode("ly", async function (srcBase, alt) {
        return makeLy(this, srcBase, alt);
    });

    eleventyConfig.addShortcode("refs", async function () {
        const lib = await parseLibrary(this.page.inputPath);
        lib.entries.sort((e0, e1) => {
            const lastName0 = e0.fields.author[0].lastName;
            const lastName1 = e1.fields.author[0].lastName;

            if (lastName0 > lastName1) {
                return 1;
            } else if (lastName0 < lastName1) {
                return -1;
            }

            const firstName0 = e0.fields.author[0].firstName;
            const firstName1 = e1.fields.author[0].firstName;

            if (firstName0 > firstName1) {
                return 1;
            } else if (firstName0 < firstName1) {
                return -1;
            }

            const year0 = e0.fields.year;
            const year1 = e1.fields.year;

            if (year0 > year1) {
                return 1;
            } else if (year0 < year1) {
                return -1;
            }

            return 0;
        });

        let refs = `<section role="doc-endnotes">${makeHeading(
            "References",
            "refs",
            2,
        )}<table class="refs-table"><tbody>`;
        for (const entry of lib.entries) {
            refs +=
                `<tr id="BIB--${entry.key}">` +
                `<th scope="row"><a href="#BIB--${entry.key}">` +
                `<span class="src-brace">[</span>${getCiteId(
                    entry.fields,
                )}<span class="src-brace">]</span>` +
                '</a></th><td itemprop="citation" ' +
                `itemscope itemtype="${entryTypeToItemType(entry.type)}">`;

            const addPersons = (arr, ed) => {
                for (const [i, person] of arr.entries()) {
                    const nameArr = [];
                    if (person.prefix) {
                        nameArr.push(
                            `<span itemprop="honorificPrefix">${person.prefix}</span>`,
                        );
                    }
                    if (person.name) {
                        nameArr.push(
                            `<span itemprop="name">${person.name}</span>`,
                        );
                    } else {
                        if (person.firstName) {
                            let first = true;
                            for (const s of person.firstName.split(WS_RE)) {
                                nameArr.push(
                                    `<span itemprop="${
                                        first ? "givenName" : "additionalName"
                                    }">${s}</span>`,
                                );
                                first = false;
                            }
                        }
                        if (person.lastName) {
                            nameArr.push(
                                `<span itemprop="familyName">${person.lastName}</span>`,
                            );
                        }
                    }
                    if (person.suffix) {
                        nameArr.push(
                            `<span itemprop="honorificSuffix">${person.suffix}</span>`,
                        );
                    }

                    const last = i === arr.length - 1;
                    const name = nameArr.join(" ");
                    const trailing = (() => {
                        if (last) {
                            if (ed) {
                                if (arr.length > 1) {
                                    return ' (<abbr title="editors">eds.</abbr>). ';
                                }

                                return ' (<abbr title="editor">ed.</abbr>). ';
                            }

                            return ". ";
                        }
                        if (arr.length === 2) {
                            return " ";
                        }

                        return ", ";
                    })();
                    refs +=
                        `${
                            last && arr.length > 1 ? "&amp; " : ""
                        }<b itemprop="${
                            ed ? "editor" : "author"
                        }" itemscope itemtype="https://schema.org/Person" ` +
                        `class="ref-author">${name}</b>${trailing}`;
                }
            };
            if (entry.fields.author) {
                addPersons(entry.fields.author);
            }

            const datetime = `${entry.fields.year}${
                entry.fields.month
                    ? "-" + entry.fields.month.padStart(2, "0")
                    : ""
            }`;
            const date = `${
                entry.fields.month ? MONTHS[+entry.fields.month - 1] + " " : ""
            }${entry.fields.year}`;
            refs +=
                '<time itemprop="datePublished" class="ref-time" ' +
                `datetime="${datetime}">${date}</time>. `;

            if (entry.fields.booktitle || entry.fields.journal) {
                refs += `\u{201c}<span itemprop="name headline">${entry.fields.title
                    .replaceAll("\u{201c}", "\u{2018}")
                    .replaceAll(
                        "\u{201d}",
                        "\u{2019}",
                    )}</span>\u{201d}, in <span itemprop="isPartOf" itemscope itemtype="https://schema.org/${
                    entry.fields.volume || entry.fields.number
                        ? "PublicationIssue"
                        : "Periodical"
                }"><cite itemprop="name headline">${
                    entry.fields.booktitle
                        ? entry.fields.booktitle
                        : entry.fields.journal
                }</cite>`;
                if (entry.fields.volume) {
                    refs += ` vol. <span itemprop="issueNumber">${entry.fields.volume}`;
                    if (entry.fields.number) {
                        refs += ` (${entry.fields.number})</span>`;
                    } else {
                        refs += "</span>";
                    }
                } else if (entry.fields.number) {
                    refs += ` (<span itemprop="issueNumber">${entry.fields.number}</span>)`;
                }
                refs += "</span>";
            } else {
                refs += `<cite itemprop="name headline">${entry.fields.title}</cite>`;
            }
            if (entry.fields.series) {
                refs += ` (<span itemprop="isPartOf">${entry.fields.series}</span>)`;
            }
            if (entry.fields.edition) {
                const ed = parseInt(entry.fields.edition, 10);
                refs += `, <span itemprop="version">${ed}<sup>${enOrdSuffix(
                    ed,
                )}</sup> edition</span>`;
            }
            if (entry.fields.chapter) {
                refs += `, ch. ${entry.fields.chapter}`;
            }
            if (entry.fields.pages) {
                refs += `, ${
                    entry.fields.pages.includes("\u{2013}") ? "p" : ""
                }p. <span itemprop="pagination">${entry.fields.pages}</span>`;
            }
            refs += ". ";

            if (entry.fields.editor) {
                addPersons(entry.fields.editor, true);
            }

            if (entry.fields.publisher) {
                for (const [
                    i,
                    publisher,
                ] of entry.fields.publisher.entries()) {
                    refs += `<span itemprop="publisher">${publisher}</span>`;
                    if (i !== entry.fields.publisher.length - 1) {
                        refs += ", ";
                    }
                }
                if (entry.fields.address) {
                    refs += `; ${entry.fields.address}`;
                }
                refs += ".";
            } else if (entry.fields.address) {
                refs += entry.fields.address + ".";
            }

            if (entry.fields.howpublished) {
                refs += ` Published at: ${entry.fields.howpublished
                    .replaceAll("<a", '<code><a itemprop="url"')
                    .replaceAll("</a>", "</a></code>")}.`;
            }

            if (entry.fields.doi) {
                refs += ` <code><a itemprop="sameAs" href="https://doi.org/${entry.fields.doi}">doi:${entry.fields.doi}</a></code>`;
            }

            refs = refs.trimEnd();
            refs += "</td></tr>";
        }
        refs = refs.replaceAll("'", "\u{2019}");
        refs += "</tbody></table></section>";

        return refs;
    });
    eleventyConfig.addShortcode("cite", async function (key, inline) {
        const citeId = await getCiteIdByKey(this.page.inputPath, key);
        const tagName = inline ? "span" : "sup";

        return (
            `<${tagName} class="cite"><a href="#BIB--${key}">` +
            `<span class="src-brace">[</span>${citeId}<span class="src-brace">]</span>` +
            `</a></${tagName}>`
        );
    });

    eleventyConfig.addShortcode(
        "caesura",
        () => '<span style="font-style: normal;">&Vert;</span>',
    );

    eleventyConfig.addPairedShortcode("toc", (content, tocDetails) => {
        let toc = '<nav class="toc" aria-label="table of contents">';
        if (tocDetails) {
            toc += "<details><summary>Table of contents</summary>";
        }

        const $ = cheerio.load(content, null, false);
        const headings = $("h1, h2, h3, h4, h5, h6");
        const levelStack = [0];
        for (const heading of headings) {
            $(heading)
                .find("a")
                .each((_, a) => {
                    a.tagName = "SPAN";
                    $(a)
                        .removeAttr("href")
                        .removeAttr("referrerpolicy")
                        .removeAttr("rel")
                        .removeAttr("target")
                        .removeAttr("download")
                        .removeAttr("itemprop");
                });

            const level = parseInt(heading.tagName[1], 10);

            while (level < levelStack[levelStack.length - 1]) {
                levelStack.pop();
                toc += "</li></ol>";
            }

            if (level > levelStack[levelStack.length - 1]) {
                levelStack.push(level);
                toc += "<ol><li>";
            } else {
                toc += "</li><li>";
            }
            toc += `<a href="#${$(heading).attr("id")}">`;
            toc += $(heading).html();
            toc += "</a>";
        }

        toc += "</li></ol>".repeat(levelStack.length - 1);

        return toc + (tocDetails ? "</details>" : "") + "</nav>" + content;
    });

    eleventyConfig.addFilter("serieses", function () {
        const serieses = [];
        for (const post of this.ctx.collections.all.filter(
            p => p.data.series,
        )) {
            const series = serieses.find(s => s.name === post.data.series);
            if (series) {
                series.posts.push(post);
            } else {
                serieses.push({ name: post.data.series, posts: [post] });
            }
        }

        return serieses;
    });
    eleventyConfig.addFilter("commontags", posts => {
        const tagCounts = new Map();
        posts.forEach(post =>
            post.data.tags.forEach(tag =>
                tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1),
            ),
        );

        return Array.from(tagCounts.entries())
            .sort(([, ct0], [, ct1]) => ct1 - ct0)
            .reduce((accu, [tag, ct]) => {
                if (ct > 1) {
                    accu.push(tag);
                }

                return accu;
            }, []);
    });

    eleventyConfig.addShortcode("articleHeader", function () {
        const date = this.ctx.date;
        const tagsList = this.ctx.tags
            .map(
                tag =>
                    `<a href="${tagPaths.get(
                        tag,
                    )}" rel="tag" itemprop="keywords">${htmlEsc(tag)}</a>`,
            )
            .join(", ");
        const series = this.ctx.series;

        return `<header class="article-header"><span id="start"></span>\
<div class="post-info">\
<time itemprop="datePublished" datetime="${date}">${date}</time>\
<span class="tags-listing"><span class="tags-colon">Tags:&nbsp;</span>\
<span class="tags-list">${tagsList}</span></span>\
</div></header>${
            series
                ? '<div class="assoc-series"><span class="part-of-a-series">' +
                  'Part of a series:</span> <cite itemprop="isPartOf" ' +
                  'itemscope itemtype="https://schema.org/Collection">' +
                  '<a href="/series/#' +
                  slugify(series) +
                  '" itemprop="url"><span itemprop="name headline">' +
                  htmlEsc(series) +
                  "</span></a></cite></div>"
                : ""
        }`;
    });

    const TEXT = 0;
    const ANNOT = 1;
    const ANNOT_EXIT = 2;
    eleventyConfig.addShortcode(
        "ruby",
        (text, lang, annotLang, annotLang1) => {
            let state = TEXT;
            const components = [[""]];
            for (const c of text) {
                switch (c) {
                    case "{": {
                        switch (state) {
                            case TEXT:
                            case ANNOT_EXIT: {
                                state = ANNOT;
                                components[components.length - 1].push("");

                                break;
                            }
                            case ANNOT: {
                                console.error(
                                    "Stray `{` inside of ruby annotation: " +
                                        text,
                                );

                                throw "";
                            }
                        }

                        break;
                    }
                    case "}": {
                        switch (state) {
                            case TEXT:
                            case ANNOT_EXIT: {
                                console.error(
                                    "Stray `}` with no preceding `{`: " + text,
                                );

                                throw "";
                            }
                            case ANNOT: {
                                state = ANNOT_EXIT;

                                break;
                            }
                        }

                        break;
                    }
                    default: {
                        switch (state) {
                            case TEXT:
                            case ANNOT: {
                                const comp = components[components.length - 1];
                                comp[comp.length - 1] += c;

                                break;
                            }
                            case ANNOT_EXIT: {
                                state = TEXT;
                                components.push([c]);

                                break;
                            }
                        }

                        break;
                    }
                }
            }

            let ret = lang ? `<ruby lang="${lang}">` : "<ruby>";
            const annotLangAttr = annotLang ? ` lang="${annotLang}"` : "";
            const annotLang1Attr = annotLang1 ? ` lang="${annotLang1}"` : "";
            for (const comp of components) {
                if (comp.length > 2) {
                    ret += "<ruby>".repeat(comp.length - 2);
                }
                for (const [i, subcomp] of comp.entries()) {
                    switch (i) {
                        case 0: {
                            ret += subcomp;

                            break;
                        }
                        case 1: {
                            ret += `<rp>(</rp><rt${annotLangAttr}>${subcomp}</rt><rp>)</rp>`;

                            break;
                        }
                        default: {
                            ret += `</ruby><rp>(</rp><rt${annotLang1Attr}>${subcomp}</rt><rp>)</rp>`;

                            break;
                        }
                    }
                }
            }
            ret += "</ruby>";

            return ret;
        },
    );

    function figTypeToText(type, abbr, plural = false) {
        const s = plural ? "s" : "";

        switch (type) {
            case "fig":
                return abbr
                    ? `<abbr class="fig-type" title="Figure${s}">Fig${s}.</abbr>`
                    : "Figure" + s;
            case "table":
                return "Table" + s;
            case "list":
                return "List" + s;
            case "verse":
                return "Poem" + s;
            default: {
                console.error(`Unknown figure type: "${type}"`);

                throw "";
            }
        }
    }
    function figTypeInitial(type) {
        switch (type) {
            case "list":
                return '<abbr class="fig-type" title="List">L</abbr>';
            default: {
                console.error(`Cannot make initial of figure type "${type}"`);

                throw "";
            }
        }
    }
    eleventyConfig.addShortcode("figref", (id, type = "fig", li, noInit) => {
        if (li) {
            return `<a href="#${id}-${li}">(${
                noInit ? "" : figTypeInitial(type) + "{{{#" + id + "--N}}}"
            }${li}.)</a>`;
        }

        const typeText = figTypeToText(type, true, id instanceof Array);
        if (id instanceof Array) {
            if (id.length === 2) {
                return `${typeText}&nbsp;<a href="#${id[0]}">{{{#${id[0]}--N}}}</a> &amp; <a href="#${id[1]}">{{{#${id[1]}--N}}}</a>`;
            }

            let ret = typeText + "&nbsp;";
            for (let i = 0; i < id.length; ++i) {
                ret += `<a href="#${id[i]}">{{{#${id[i]}--N}}}</a>`;
                if (i === id.length - 1) {
                } else if (i === id.length - 2) {
                    ret += ", &amp; ";
                } else {
                    ret += ", ";
                }
            }

            return ret;
        }

        return `<a href="#${id}">${typeText}&nbsp;{{{#${id}--N}}}</a>`;
    });
    eleventyConfig.addPairedShortcode("fig", (content, id, type = "fig") => {
        const $ = cheerio.load(
            `<figure id="${id}" data-fig-type="${type}">${content}</figure>`,
            null,
            false,
        );
        const firstP = $(`#${id} > figcaption > p`)[0];
        $(firstP).prepend(" ");
        $(firstP).prepend(
            `<b><a href="#${id}" class="figcaption-label">${figTypeToText(
                type,
            )} {{{#${id}--N}}}</a>:</b>`,
        );

        return $.html();
    });
    eleventyConfig.addPairedShortcode("figdomain", content => {
        const $ = cheerio.load(content, null, false);
        const figs = $("figure[id][data-fig-type]");
        const ordering = new Map();
        for (const fig of figs) {
            const id = $(fig).attr("id");
            const figType = $(fig).attr("data-fig-type");
            if (ordering.has(figType)) {
                ordering.get(figType).push(id);
            } else {
                ordering.set(figType, [id]);
            }
        }

        ordering.forEach(ids =>
            ids.forEach(
                (id, i) =>
                    (content = content.replaceAll(
                        `{{{#${id}--N}}}`,
                        `${1 + i}`,
                    )),
            ),
        );

        return content;
    });
    eleventyConfig.addPairedShortcode("glosslist", content => {
        const $ = cheerio.load(
            `<dl class="gloss-list">${content}</dl>`,
            null,
            false,
        );
        for (const dt of $("dl:first > dt")) {
            const text = $(dt).text().trim();
            const innerHtml = $(dt).html();
            const id = "gloss-" + slugify(text);
            $(dt).attr("id", id);
            $(dt).attr("itemprop", "hasDefinedTerm");
            $(dt).html(`<a href="#${id}">${innerHtml}</a>`);
        }

        return $.html();
    });
    eleventyConfig.addShortcode(
        "gloss",
        (term, text) =>
            `<a class="gloss-ref" href="/glossary/#gloss-${slugify(term)}">${
                text ? text : term
            }</a>`,
    );
    const CODEPOINT_CAPITAL_A = 0x0041;
    const CODEPOINT_SMALL_A = 0x0061;
    const ROMAN = [
        ["M", 1_000],
        ["CM", 900],
        ["D", 500],
        ["CD", 400],
        ["C", 100],
        ["XC", 90],
        ["L", 50],
        ["XL", 40],
        ["X", 10],
        ["IX", 9],
        ["V", 5],
        ["IV", 4],
        ["I", 1],
    ];
    function liMarker(n, type = "1") {
        if (type !== "1" && n < 1) {
            console.error(`n < 1 for marker type "${type}"`);

            throw "";
        }

        const letterMarker = baseCodepoint => {
            const n0 = n - 1;
            if (n0 < 26) {
                return String.fromCodePoint(baseCodepoint + n0);
            }
            const residue = n0 % 26;

            return (
                liMarker((n0 - residue) / 26, type) +
                String.fromCodePoint(baseCodepoint + residue)
            );
        };
        const romanMarker = lower =>
            ROMAN.reduce(
                ([m, s], [r, rVal]) => {
                    const q = Math.trunc(m / rVal);

                    return [
                        m - q * rVal,
                        s + (lower ? r.toLowerCase() : r).repeat(q),
                    ];
                },
                [n, ""],
            )[1];
        switch (type) {
            case "1":
                return "" + n;
            case "A":
                return letterMarker(CODEPOINT_CAPITAL_A);
            case "a":
                return letterMarker(CODEPOINT_SMALL_A);
            case "I":
                return romanMarker();
            case "i":
                return romanMarker(true);
            default: {
                console.error(`Unrecognized <li> marker type: "${type}"`);

                throw "";
            }
        }
    }
    eleventyConfig.addPairedShortcode("ol", (content, baseId, type = "1") => {
        const $ = cheerio.load(
            `<ol type="${type}">${content}</ol>`,
            null,
            false,
        );
        $("ol:first > li").each((i, elem) =>
            $(elem).attr("id", `${baseId}-${liMarker(i + 1, type)}`),
        );

        return $.html();
    });

    eleventyConfig.addPairedShortcode(
        "enmark",
        (content, id) =>
            `<span class="enmark-span">${content}<sup class="enmark"><a id="${id}--MARK" href="#${id}">[?]</a></sup></span>`,
    );
    eleventyConfig.addPairedShortcode(
        "en",
        (content, id) =>
            `<li class="en" id="${id}"><a class="en-arrow" href="#${id}--MARK" aria-label="back to the main text">&larrhk;&#xfe0e;</a> ${content}</li>`,
    );
    eleventyConfig.addPairedShortcode("endomain", (content, lvl) => {
        const $ = cheerio.load(content, null, false);

        const firstHeading = $("h1, h2, h3, h4, h5, h6")[0];
        const level = lvl
            ? lvl
            : Math.min(parseInt(firstHeading.tagName[1], 10) + 1, 6);

        const ens = $("li.en");
        const enIds = [];
        for (const en of ens) {
            enIds.push($(en).attr("id"));
        }

        const enMarks = $("sup.enmark > a");
        const enMarkIds = new Set();
        for (const enMark of enMarks) {
            const id = $(enMark).attr("id");
            if (enMarkIds.has(id)) {
                $(enMark).removeAttr("id");
            } else {
                enMarkIds.add(id);
            }

            const enId = $(enMark).attr("href").slice(1);
            const ix = enIds.indexOf(enId);
            $(enMark).text(`[${1 + ix}]`);
        }

        const firstHeadingId = $(firstHeading).attr("id");
        const olId = `${firstHeadingId}--ENS`;
        $(
            `<section role="doc-endnotes">${makeHeading(
                "Endnotes",
                `${firstHeadingId}-endnotes`,
                level,
            )}<ol type="1" id="${olId}"></ol></section>`,
        ).insertBefore($("li.en").get(0));
        ens.remove();
        ens.appendTo(`#${olId}`);

        return $.html();
    });

    eleventyConfig.addTransform("hyphens", function (content) {
        if (this.page.url === "/atom.xml") {
            return content;
        }

        const $ = cheerio.load(content);
        replaceHyphenMinuses($("body")[0]);

        return $.html();
    });
    const externalUriRe = /^([a-zA-Z][a-zA-Z0-9\+\.\-]*:.|\/\/)/;
    eleventyConfig.addTransform("addrels", function (content) {
        if (this.page.url === "/atom.xml") {
            return content;
        }

        const $ = cheerio.load(content);
        $("a").each((_, a) => {
            const href = $(a).attr("href");
            if (externalUriRe.test(href)) {
                let rel = ($(a).attr("rel") ?? "").trim();
                if (rel.length > 0) {
                    rel += " ";
                }
                rel += "external noopener noreferrer";
                $(a).attr("rel", rel);
            }
        });

        return $.html();
    });
    const hasLetterRe = /[a-zA-Z]/;
    eleventyConfig.addTransform("datetimes", function (content) {
        if (this.page.url === "/atom.xml") {
            return content;
        }

        const $ = cheerio.load(content);
        $("time[datetime]").each((_, time) => {
            const datetime = $(time).attr("datetime");
            if (!$(time).attr("title") && hasLetterRe.test(datetime)) {
                $(time).attr("title", datetime);
            }
        });

        return $.html();
    });
    eleventyConfig.addTransform("titlelangs", function (content) {
        if (this.page.url === "/atom.xml") {
            return content;
        }

        const $ = cheerio.load(content);
        $("[lang]").each((_, elem) => {
            const lang = $(elem).attr("lang");
            const title = $(elem).attr("title");
            if (!lang || !!title) {
                return;
            }

            const newTitle = langToTitle(lang);
            $(elem).attr("title", newTitle);
        });

        return $.html();
    });

    eleventyConfig.setFrontMatterParsingOptions({
        language: "json",
    });

    return {
        dir: {
            input: "src",
            output: "dist",
        },
        htmlTemplateEngine: "njk",
    };
}
